--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 666c90875aa810547703b5d00d304e8dd1917a4f
Parents : 30cab64
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-01-05T17:38:52-06:00
interface discovery, folders for messages, map nodes from discovery, maintenance tools.
Changes
26 files changed, 3256 insertions(+), 278 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index d5a2c266..ed270405 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -42,6 +42,7 @@ from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
+from RNS.Discovery import InterfaceDiscovery
from serial.tools import list_ports
from meshchatx.src.backend.async_utils import AsyncUtils
@@ -2152,8 +2153,37 @@ class ReticulumMeshChat:
@routes.get("/api/v1/database/snapshots")
async def list_db_snapshots(request):
try:
+ limit = int(request.query.get("limit", 100))
+ offset = int(request.query.get("offset", 0))
snapshots = self.database.list_snapshots(self.storage_dir)
- return web.json_response(snapshots)
+ total = len(snapshots)
+ paginated_snapshots = snapshots[offset : offset + limit]
+ return web.json_response(
+ {
+ "snapshots": paginated_snapshots,
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ },
+ )
+ except Exception as e:
+ return web.json_response(
+ {"status": "error", "message": str(e)},
+ status=500,
+ )
+
+ @routes.delete("/api/v1/database/snapshots/{filename}")
+ async def delete_db_snapshot(request):
+ try:
+ filename = request.match_info.get("filename")
+ if not filename.endswith(".zip"):
+ filename += ".zip"
+ self.database.delete_snapshot_or_backup(
+ self.storage_dir,
+ filename,
+ is_backup=False,
+ )
+ return web.json_response({"status": "success"})
except Exception as e:
return web.json_response(
{"status": "error", "message": str(e)},
@@ -2199,9 +2229,13 @@ class ReticulumMeshChat:
@routes.get("/api/v1/database/backups")
async def list_db_backups(request):
try:
+ limit = int(request.query.get("limit", 100))
+ offset = int(request.query.get("offset", 0))
backup_dir = os.path.join(self.storage_dir, "database-backups")
if not os.path.exists(backup_dir):
- return web.json_response([])
+ return web.json_response(
+ {"backups": [], "total": 0, "limit": limit, "offset": offset},
+ )
backups = []
for file in os.listdir(backup_dir):
@@ -2219,9 +2253,39 @@ class ReticulumMeshChat:
).isoformat(),
},
)
+ sorted_backups = sorted(
+ backups,
+ key=lambda x: x["created_at"],
+ reverse=True,
+ )
+ total = len(sorted_backups)
+ paginated_backups = sorted_backups[offset : offset + limit]
+ return web.json_response(
+ {
+ "backups": paginated_backups,
+ "total": total,
+ "limit": limit,
+ "offset": offset,
+ },
+ )
+ except Exception as e:
return web.json_response(
- sorted(backups, key=lambda x: x["created_at"], reverse=True),
+ {"status": "error", "message": str(e)},
+ status=500,
+ )
+
+ @routes.delete("/api/v1/database/backups/{filename}")
+ async def delete_db_backup(request):
+ try:
+ filename = request.match_info.get("filename")
+ if not filename.endswith(".zip"):
+ filename += ".zip"
+ self.database.delete_snapshot_or_backup(
+ self.storage_dir,
+ filename,
+ is_backup=True,
)
+ return web.json_response({"status": "success"})
except Exception as e:
return web.json_response(
{"status": "error", "message": str(e)},
@@ -3360,6 +3424,7 @@ class ReticulumMeshChat:
),
"ply": self.get_package_version("ply"),
"bcrypt": self.get_package_version("bcrypt"),
+ "lxmfy": self.get_package_version("lxmfy"),
},
"storage_path": self.storage_path,
"database_path": self.database_path,
@@ -3939,6 +4004,62 @@ class ReticulumMeshChat:
status=500,
)
+ # maintenance - clear messages
+ @routes.delete("/api/v1/maintenance/messages")
+ async def maintenance_clear_messages(request):
+ self.database.messages.delete_all_lxmf_messages()
+ return web.json_response({"message": "All messages cleared"})
+
+ # maintenance - clear announces
+ @routes.delete("/api/v1/maintenance/announces")
+ async def maintenance_clear_announces(request):
+ aspect = request.query.get("aspect")
+ self.database.announces.delete_all_announces(aspect=aspect)
+ return web.json_response(
+ {
+ "message": f"Announces cleared{' for aspect ' + aspect if aspect else ''}",
+ },
+ )
+
+ # maintenance - clear favorites
+ @routes.delete("/api/v1/maintenance/favourites")
+ async def maintenance_clear_favourites(request):
+ aspect = request.query.get("aspect")
+ self.database.announces.delete_all_favourites(aspect=aspect)
+ return web.json_response(
+ {
+ "message": f"Favourites cleared{' for aspect ' + aspect if aspect else ''}",
+ },
+ )
+
+ # maintenance - clear archives
+ @routes.delete("/api/v1/maintenance/archives")
+ async def maintenance_clear_archives(request):
+ self.database.misc.delete_archived_pages()
+ return web.json_response({"message": "All archived pages cleared"})
+
+ # maintenance - export messages
+ @routes.get("/api/v1/maintenance/messages/export")
+ async def maintenance_export_messages(request):
+ messages = self.database.messages.get_all_lxmf_messages()
+ # Convert sqlite3.Row to dict if necessary
+ messages_list = [dict(m) for m in messages]
+ return web.json_response({"messages": messages_list})
+
+ # maintenance - import messages
+ @routes.post("/api/v1/maintenance/messages/import")
+ async def maintenance_import_messages(request):
+ try:
+ data = await request.json()
+ messages = data.get("messages", [])
+ for msg in messages:
+ self.database.messages.upsert_lxmf_message(msg)
+ return web.json_response(
+ {"message": f"Successfully imported {len(messages)} messages"},
+ )
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=400)
+
# get config
@routes.get("/api/v1/config")
async def config_get(request):
@@ -4043,6 +4164,91 @@ class ReticulumMeshChat:
return web.json_response({"discovery": discovery_config})
+ @routes.get("/api/v1/reticulum/discovered-interfaces")
+ async def reticulum_discovered_interfaces(request):
+ try:
+ discovery = InterfaceDiscovery(discover_interfaces=False)
+ interfaces = discovery.list_discovered_interfaces()
+ active = []
+ try:
+ if hasattr(self, "reticulum") and self.reticulum:
+ stats = self.reticulum.get_interface_stats().get(
+ "interfaces",
+ [],
+ )
+ active = []
+ for s in stats:
+ name = s.get("name") or ""
+ parsed_host = None
+ parsed_port = None
+ if "/" in name:
+ try:
+ host_port = name.split("/")[-1].strip("[]")
+ if ":" in host_port:
+ parsed_host, parsed_port = host_port.rsplit(
+ ":",
+ 1,
+ )
+ try:
+ parsed_port = int(parsed_port)
+ except Exception:
+ parsed_port = None
+ else:
+ parsed_host = host_port
+ except Exception:
+ parsed_host = None
+ parsed_port = None
+
+ host = (
+ s.get("target_host") or s.get("remote") or parsed_host
+ )
+ port = (
+ s.get("target_port")
+ or s.get("listen_port")
+ or parsed_port
+ )
+ transport_id = s.get("transport_id")
+ if isinstance(transport_id, (bytes, bytearray)):
+ transport_id = transport_id.hex()
+
+ active.append(
+ {
+ "name": name,
+ "short_name": s.get("short_name"),
+ "type": s.get("type"),
+ "target_host": host,
+ "target_port": port,
+ "listen_ip": s.get("listen_ip"),
+ "connected": s.get("connected"),
+ "online": s.get("online"),
+ "transport_id": transport_id,
+ "network_id": s.get("network_id"),
+ },
+ )
+ except Exception as e:
+ logger.debug(f"Failed to get interface stats: {e}")
+
+ def to_jsonable(obj):
+ if isinstance(obj, bytes):
+ return obj.hex()
+ if isinstance(obj, dict):
+ return {k: to_jsonable(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [to_jsonable(v) for v in obj]
+ return obj
+
+ return web.json_response(
+ {
+ "interfaces": to_jsonable(interfaces),
+ "active": to_jsonable(active),
+ },
+ )
+ except Exception as e:
+ return web.json_response(
+ {"message": f"Failed to load discovered interfaces: {e!s}"},
+ status=500,
+ )
+
# enable transport mode
@routes.post("/api/v1/reticulum/enable-transport")
async def reticulum_enable_transport(request):
@@ -6920,6 +7126,12 @@ class ReticulumMeshChat:
request.query.get("filter_has_attachments", "false"),
),
)
+ folder_id = request.query.get("folder_id")
+ if folder_id is not None:
+ try:
+ folder_id = int(folder_id)
+ except ValueError:
+ folder_id = None
# get pagination params
try:
@@ -6943,6 +7155,7 @@ class ReticulumMeshChat:
filter_unread=filter_unread,
filter_failed=filter_failed,
filter_has_attachments=filter_has_attachments,
+ folder_id=folder_id,
limit=limit,
offset=offset,
)
@@ -7021,6 +7234,123 @@ class ReticulumMeshChat:
},
)
+ @routes.get("/api/v1/lxmf/folders")
+ async def lxmf_folders_get(request):
+ folders = self.database.messages.get_all_folders()
+ return web.json_response([dict(f) for f in folders])
+
+ @routes.post("/api/v1/lxmf/folders")
+ async def lxmf_folders_post(request):
+ data = await request.json()
+ name = data.get("name")
+ if not name:
+ return web.json_response({"message": "Name is required"}, status=400)
+ try:
+ self.database.messages.create_folder(name)
+ return web.json_response({"message": "Folder created"})
+ except Exception as e:
+ return web.json_response({"message": str(e)}, status=500)
+
+ @routes.patch("/api/v1/lxmf/folders/{id}")
+ async def lxmf_folders_patch(request):
+ folder_id = int(request.match_info["id"])
+ data = await request.json()
+ name = data.get("name")
+ if not name:
+ return web.json_response({"message": "Name is required"}, status=400)
+ self.database.messages.rename_folder(folder_id, name)
+ return web.json_response({"message": "Folder renamed"})
+
+ @routes.delete("/api/v1/lxmf/folders/{id}")
+ async def lxmf_folders_delete(request):
+ folder_id = int(request.match_info["id"])
+ self.database.messages.delete_folder(folder_id)
+ return web.json_response({"message": "Folder deleted"})
+
+ @routes.post("/api/v1/lxmf/conversations/move-to-folder")
+ async def lxmf_conversations_move_to_folder(request):
+ data = await request.json()
+ peer_hashes = data.get("peer_hashes", [])
+ folder_id = data.get("folder_id") # Can be None to remove from folder
+ if not peer_hashes:
+ return web.json_response(
+ {"message": "peer_hashes is required"},
+ status=400,
+ )
+ self.database.messages.move_conversations_to_folder(peer_hashes, folder_id)
+ return web.json_response({"message": "Conversations moved"})
+
+ @routes.post("/api/v1/lxmf/conversations/bulk-mark-as-read")
+ async def lxmf_conversations_bulk_mark_read(request):
+ data = await request.json()
+ destination_hashes = data.get("destination_hashes", [])
+ if not destination_hashes:
+ return web.json_response(
+ {"message": "destination_hashes is required"},
+ status=400,
+ )
+ self.database.messages.mark_conversations_as_read(destination_hashes)
+ return web.json_response({"message": "Conversations marked as read"})
+
+ @routes.post("/api/v1/lxmf/conversations/bulk-delete")
+ async def lxmf_conversations_bulk_delete(request):
+ data = await request.json()
+ destination_hashes = data.get("destination_hashes", [])
+ if not destination_hashes:
+ return web.json_response(
+ {"message": "destination_hashes is required"},
+ status=400,
+ )
+ local_hash = self.local_lxmf_destination.hexhash
+ for dest_hash in destination_hashes:
+ self.message_handler.delete_conversation(local_hash, dest_hash)
+ return web.json_response({"message": "Conversations deleted"})
+
+ @routes.get("/api/v1/lxmf/folders/export")
+ async def lxmf_folders_export(request):
+ folders = [dict(f) for f in self.database.messages.get_all_folders()]
+ mappings = [
+ dict(m) for m in self.database.messages.get_all_conversation_folders()
+ ]
+ return web.json_response({"folders": folders, "mappings": mappings})
+
+ @routes.post("/api/v1/lxmf/folders/import")
+ async def lxmf_folders_import(request):
+ data = await request.json()
+ folders = data.get("folders", [])
+ mappings = data.get("mappings", [])
+
+ # We'll try to recreate folders by name to avoid ID conflicts
+ folder_name_to_new_id = {}
+ for f in folders:
+ try:
+ self.database.messages.create_folder(f["name"])
+ except Exception as e:
+ logger.debug(f"Folder '{f['name']}' likely already exists: {e}")
+
+ # Refresh folder list to get new IDs
+ all_folders = self.database.messages.get_all_folders()
+ for f in all_folders:
+ folder_name_to_new_id[f["name"]] = f["id"]
+
+ # Map old IDs to new IDs if possible, or just use names if we had them
+ # Since IDs might change, we should have exported names too
+ # Let's assume the export had folder names in mappings or we match by old folder info
+ old_id_to_name = {f["id"]: f["name"] for f in folders}
+
+ for m in mappings:
+ peer_hash = m["peer_hash"]
+ old_folder_id = m["folder_id"]
+ folder_name = old_id_to_name.get(old_folder_id)
+ if folder_name and folder_name in folder_name_to_new_id:
+ new_folder_id = folder_name_to_new_id[folder_name]
+ self.database.messages.move_conversation_to_folder(
+ peer_hash,
+ new_folder_id,
+ )
+
+ return web.json_response({"message": "Folders and mappings imported"})
+
# mark lxmf conversation as read
@routes.get("/api/v1/lxmf/conversations/{destination_hash}/mark-as-read")
async def lxmf_conversations_mark_read(request):
@@ -7806,7 +8136,7 @@ class ReticulumMeshChat:
f"connect-src {' '.join(connect_sources)}; "
"media-src 'self' blob:; "
"worker-src 'self' blob:; "
- "frame-src 'self'; "
+ "frame-src 'self' https://reticulum.network; "
"object-src 'none'; "
"base-uri 'self';"
)
@@ -7922,17 +8252,24 @@ class ReticulumMeshChat:
# (e.g. when running from a read-only AppImage)
if self.current_context and hasattr(self.current_context, "docs_manager"):
dm = self.current_context.docs_manager
- if (
- dm.docs_dir
- and os.path.exists(dm.docs_dir)
- and not dm.docs_dir.startswith(public_dir)
- ):
- app.router.add_static(
- "/reticulum-docs/",
- dm.docs_dir,
- name="reticulum_docs_storage",
- follow_symlinks=True,
- )
+
+ # Custom handler for reticulum docs to allow fallback to official website
+ async def reticulum_docs_handler(request):
+ path = request.match_info.get("filename", "index.html")
+ if not path:
+ path = "index.html"
+ if path.endswith("/"):
+ path += "index.html"
+
+ local_path = os.path.join(dm.docs_dir, path)
+ if os.path.exists(local_path) and os.path.isfile(local_path):
+ return web.FileResponse(local_path)
+
+ # Fallback to official website
+ return web.HTTPFound(f"https://reticulum.network/manual/{path}")
+
+ app.router.add_get("/reticulum-docs/{filename:.*}", reticulum_docs_handler)
+
if (
dm.meshchatx_docs_dir
and os.path.exists(dm.meshchatx_docs_dir)
@@ -7978,7 +8315,8 @@ class ReticulumMeshChat:
print(
f"Performing scheduled auto-backup for {ctx.identity_hash}...",
)
- ctx.database.backup_database(self.storage_dir)
+ max_count = ctx.config.backup_max_count.get()
+ ctx.database.backup_database(self.storage_dir, max_count=max_count)
except Exception as e:
print(f"Auto-backup failed: {e}")
diff --git a/meshchatx/src/backend/bot_handler.py b/meshchatx/src/backend/bot_handler.py
index 576fef9f..e5b99a19 100644
--- a/meshchatx/src/backend/bot_handler.py
+++ b/meshchatx/src/backend/bot_handler.py
@@ -240,7 +240,9 @@ class BotHandler:
shutil.rmtree(storage_dir)
except Exception as exc:
logger.warning(
- "Failed to delete storage dir for bot %s: %s", bot_id, exc
+ "Failed to delete storage dir for bot %s: %s",
+ bot_id,
+ exc,
)
self._save_state()
diff --git a/meshchatx/src/backend/config_manager.py b/meshchatx/src/backend/config_manager.py
index e8ab0939..cb4593f9 100644
--- a/meshchatx/src/backend/config_manager.py
+++ b/meshchatx/src/backend/config_manager.py
@@ -103,6 +103,7 @@ class ConfigManager:
"archives_max_storage_gb",
1,
)
+ self.backup_max_count = self.IntConfig(self, "backup_max_count", 5)
self.crawler_enabled = self.BoolConfig(self, "crawler_enabled", False)
self.crawler_max_retries = self.IntConfig(self, "crawler_max_retries", 3)
self.crawler_retry_delay_seconds = self.IntConfig(
diff --git a/meshchatx/src/backend/database/__init__.py b/meshchatx/src/backend/database/__init__.py
index 29d2ffce..6ec139a7 100644
--- a/meshchatx/src/backend/database/__init__.py
+++ b/meshchatx/src/backend/database/__init__.py
@@ -211,14 +211,41 @@ class Database:
"size": os.path.getsize(backup_path),
}
- def backup_database(self, storage_path, backup_path: str | None = None):
+ def backup_database(
+ self,
+ storage_path,
+ backup_path: str | None = None,
+ max_count: int | None = None,
+ ):
default_dir = os.path.join(storage_path, "database-backups")
os.makedirs(default_dir, exist_ok=True)
if backup_path is None:
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
backup_path = os.path.join(default_dir, f"backup-{timestamp}.zip")
- return self._backup_to_zip(backup_path)
+ result = self._backup_to_zip(backup_path)
+
+ # Cleanup old backups if a limit is set
+ if max_count is not None and max_count > 0:
+ try:
+ backups = []
+ for file in os.listdir(default_dir):
+ if file.endswith(".zip"):
+ full_path = os.path.join(default_dir, file)
+ stats = os.stat(full_path)
+ backups.append((full_path, stats.st_mtime))
+
+ if len(backups) > max_count:
+ # Sort by modification time (oldest first)
+ backups.sort(key=lambda x: x[1])
+ to_delete = backups[: len(backups) - max_count]
+ for path, _ in to_delete:
+ if os.path.exists(path):
+ os.remove(path)
+ except Exception as e:
+ print(f"Failed to cleanup old backups: {e}")
+
+ return result
def create_snapshot(self, storage_path, name: str):
"""Creates a named snapshot of the database."""
@@ -258,6 +285,29 @@ class Database:
)
return sorted(snapshots, key=lambda x: x["created_at"], reverse=True)
+ def delete_snapshot_or_backup(
+ self,
+ storage_path,
+ filename: str,
+ is_backup: bool = False,
+ ):
+ """Deletes a database snapshot or auto-backup."""
+ base_dir = "database-backups" if is_backup else "snapshots"
+ file_path = os.path.join(storage_path, base_dir, filename)
+
+ # Basic security check to ensure we stay within the intended directory
+ abs_path = os.path.abspath(file_path)
+ abs_base = os.path.abspath(os.path.join(storage_path, base_dir))
+
+ if not abs_path.startswith(abs_base):
+ msg = "Invalid path"
+ raise ValueError(msg)
+
+ if os.path.exists(abs_path):
+ os.remove(abs_path)
+ return True
+ return False
+
def restore_database(self, backup_path: str):
if not os.path.exists(backup_path):
msg = f"Backup not found at {backup_path}"
diff --git a/meshchatx/src/backend/database/announces.py b/meshchatx/src/backend/database/announces.py
index 080a1bcb..a1b626e8 100644
--- a/meshchatx/src/backend/database/announces.py
+++ b/meshchatx/src/backend/database/announces.py
@@ -54,6 +54,15 @@ class AnnounceDAO:
(destination_hash,),
)
+ def delete_all_announces(self, aspect=None):
+ if aspect:
+ self.provider.execute(
+ "DELETE FROM announces WHERE aspect = ?",
+ (aspect,),
+ )
+ else:
+ self.provider.execute("DELETE FROM announces")
+
def get_filtered_announces(
self,
aspect=None,
@@ -137,3 +146,12 @@ class AnnounceDAO:
"DELETE FROM favourite_destinations WHERE destination_hash = ?",
(destination_hash,),
)
+
+ def delete_all_favourites(self, aspect=None):
+ if aspect:
+ self.provider.execute(
+ "DELETE FROM favourite_destinations WHERE aspect = ?",
+ (aspect,),
+ )
+ else:
+ self.provider.execute("DELETE FROM favourite_destinations")
diff --git a/meshchatx/src/backend/database/messages.py b/meshchatx/src/backend/database/messages.py
index 442be84f..618d5322 100644
--- a/meshchatx/src/backend/database/messages.py
+++ b/meshchatx/src/backend/database/messages.py
@@ -63,12 +63,28 @@ class MessageDAO:
(message_hash,),
)
+ def delete_lxmf_messages_by_hashes(self, message_hashes):
+ if not message_hashes:
+ return
+ placeholders = ", ".join(["?"] * len(message_hashes))
+ self.provider.execute(
+ f"DELETE FROM lxmf_messages WHERE hash IN ({placeholders})",
+ tuple(message_hashes),
+ )
+
def delete_lxmf_message_by_hash(self, message_hash):
self.provider.execute(
"DELETE FROM lxmf_messages WHERE hash = ?",
(message_hash,),
)
+ def delete_all_lxmf_messages(self):
+ self.provider.execute("DELETE FROM lxmf_messages")
+ self.provider.execute("DELETE FROM lxmf_conversation_read_state")
+
+ def get_all_lxmf_messages(self):
+ return self.provider.fetchall("SELECT * FROM lxmf_messages")
+
def get_conversation_messages(self, destination_hash, limit=100, offset=0):
return self.provider.fetchall(
"SELECT * FROM lxmf_messages WHERE peer_hash = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
@@ -103,6 +119,22 @@ class MessageDAO:
(destination_hash, now, now, now),
)
+ def mark_conversations_as_read(self, destination_hashes):
+ if not destination_hashes:
+ return
+ now = datetime.now(UTC).isoformat()
+ for destination_hash in destination_hashes:
+ self.provider.execute(
+ """
+ INSERT INTO lxmf_conversation_read_state (destination_hash, last_read_at, created_at, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(destination_hash) DO UPDATE SET
+ last_read_at = EXCLUDED.last_read_at,
+ updated_at = EXCLUDED.updated_at
+ """,
+ (destination_hash, now, now, now),
+ )
+
def is_conversation_unread(self, destination_hash):
row = self.provider.fetchone(
"""
@@ -290,3 +322,56 @@ class MessageDAO:
last_viewed_at = last_viewed_at.replace(tzinfo=UTC)
return message_timestamp <= last_viewed_at.timestamp()
+
+ # Folders
+ def get_all_folders(self):
+ return self.provider.fetchall("SELECT * FROM lxmf_folders ORDER BY name ASC")
+
+ def create_folder(self, name):
+ now = datetime.now(UTC).isoformat()
+ return self.provider.execute(
+ "INSERT INTO lxmf_folders (name, created_at, updated_at) VALUES (?, ?, ?)",
+ (name, now, now),
+ )
+
+ def rename_folder(self, folder_id, new_name):
+ now = datetime.now(UTC).isoformat()
+ self.provider.execute(
+ "UPDATE lxmf_folders SET name = ?, updated_at = ? WHERE id = ?",
+ (new_name, now, folder_id),
+ )
+
+ def delete_folder(self, folder_id):
+ self.provider.execute("DELETE FROM lxmf_folders WHERE id = ?", (folder_id,))
+
+ def get_conversation_folder(self, peer_hash):
+ return self.provider.fetchone(
+ "SELECT * FROM lxmf_conversation_folders WHERE peer_hash = ?",
+ (peer_hash,),
+ )
+
+ def move_conversation_to_folder(self, peer_hash, folder_id):
+ now = datetime.now(UTC).isoformat()
+ if folder_id is None:
+ self.provider.execute(
+ "DELETE FROM lxmf_conversation_folders WHERE peer_hash = ?",
+ (peer_hash,),
+ )
+ else:
+ self.provider.execute(
+ """
+ INSERT INTO lxmf_conversation_folders (peer_hash, folder_id, created_at, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(peer_hash) DO UPDATE SET
+ folder_id = EXCLUDED.folder_id,
+ updated_at = EXCLUDED.updated_at
+ """,
+ (peer_hash, folder_id, now, now),
+ )
+
+ def move_conversations_to_folder(self, peer_hashes, folder_id):
+ for peer_hash in peer_hashes:
+ self.move_conversation_to_folder(peer_hash, folder_id)
+
+ def get_all_conversation_folders(self):
+ return self.provider.fetchall("SELECT * FROM lxmf_conversation_folders")
diff --git a/meshchatx/src/backend/database/schema.py b/meshchatx/src/backend/database/schema.py
index 49f8b86f..4a551cd9 100644
--- a/meshchatx/src/backend/database/schema.py
+++ b/meshchatx/src/backend/database/schema.py
@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
class DatabaseSchema:
- LATEST_VERSION = 35
+ LATEST_VERSION = 36
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -423,6 +423,24 @@ class DatabaseSchema:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
+ "lxmf_folders": """
+ CREATE TABLE IF NOT EXISTS lxmf_folders (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ """,
+ "lxmf_conversation_folders": """
+ CREATE TABLE IF NOT EXISTS lxmf_conversation_folders (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ peer_hash TEXT UNIQUE,
+ folder_id INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (folder_id) REFERENCES lxmf_folders(id) ON DELETE CASCADE
+ )
+ """,
}
for table_name, create_sql in tables.items():
@@ -933,6 +951,32 @@ class DatabaseSchema:
"ALTER TABLE contacts ADD COLUMN lxst_address TEXT DEFAULT NULL",
)
+ if current_version < 36:
+ self._safe_execute("""
+ CREATE TABLE IF NOT EXISTS lxmf_folders (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT UNIQUE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ self._safe_execute("""
+ CREATE TABLE IF NOT EXISTS lxmf_conversation_folders (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ peer_hash TEXT UNIQUE,
+ folder_id INTEGER,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (folder_id) REFERENCES lxmf_folders(id) ON DELETE CASCADE
+ )
+ """)
+ self._safe_execute(
+ "CREATE INDEX IF NOT EXISTS idx_lxmf_conversation_folders_peer_hash ON lxmf_conversation_folders(peer_hash)",
+ )
+ self._safe_execute(
+ "CREATE INDEX IF NOT EXISTS idx_lxmf_conversation_folders_folder_id ON lxmf_conversation_folders(folder_id)",
+ )
+
# Update version in config
self._safe_execute(
"""
diff --git a/meshchatx/src/backend/docs_manager.py b/meshchatx/src/backend/docs_manager.py
index 64c4335b..d78d0317 100644
--- a/meshchatx/src/backend/docs_manager.py
+++ b/meshchatx/src/backend/docs_manager.py
@@ -38,7 +38,12 @@ class DocsManager:
# Ensure docs directories exist
try:
- for d in [self.docs_base_dir, self.versions_dir, self.meshchatx_docs_dir]:
+ for d in [
+ self.docs_base_dir,
+ self.versions_dir,
+ self.docs_dir,
+ self.meshchatx_docs_dir,
+ ]:
if not os.path.exists(d):
os.makedirs(d)
@@ -423,8 +428,6 @@ class DocsManager:
def has_docs(self):
# Check if index.html exists in the docs folder or if we have any versions
- if self.config.docs_downloaded.get():
- return True
return (
os.path.exists(os.path.join(self.docs_dir, "index.html"))
or len(self.get_available_versions()) > 0
diff --git a/meshchatx/src/backend/message_handler.py b/meshchatx/src/backend/message_handler.py
index 4fb1c8a7..5a3deeb1 100644
--- a/meshchatx/src/backend/message_handler.py
+++ b/meshchatx/src/backend/message_handler.py
@@ -35,6 +35,11 @@ class MessageHandler:
def delete_conversation(self, local_hash, destination_hash):
query = "DELETE FROM lxmf_messages WHERE peer_hash = ?"
self.db.provider.execute(query, [destination_hash])
+ # Also clean up folder mapping
+ self.db.provider.execute(
+ "DELETE FROM lxmf_conversation_folders WHERE peer_hash = ?",
+ [destination_hash],
+ )
def search_messages(self, local_hash, search_term):
like_term = f"%{search_term}%"
@@ -54,6 +59,7 @@ class MessageHandler:
filter_unread=False,
filter_failed=False,
filter_has_attachments=False,
+ folder_id=None,
limit=None,
offset=0,
):
@@ -66,6 +72,8 @@ class MessageHandler:
con.custom_image as contact_image,
i.icon_name, i.foreground_colour, i.background_colour,
r.last_read_at,
+ f.id as folder_id,
+ fn.name as folder_name,
(SELECT COUNT(*) FROM lxmf_messages m_failed
WHERE m_failed.peer_hash = m1.peer_hash AND m_failed.state = 'failed') as failed_count
FROM lxmf_messages m1
@@ -84,10 +92,20 @@ class MessageHandler:
)
LEFT JOIN lxmf_user_icons i ON i.destination_hash = m1.peer_hash
LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = m1.peer_hash
+ LEFT JOIN lxmf_conversation_folders f ON f.peer_hash = m1.peer_hash
+ LEFT JOIN lxmf_folders fn ON fn.id = f.folder_id
"""
params = []
where_clauses = []
+ if folder_id is not None:
+ if folder_id == 0 or folder_id == "0":
+ # Special case: no folder (Uncategorized)
+ where_clauses.append("f.folder_id IS NULL")
+ else:
+ where_clauses.append("f.folder_id = ?")
+ params.append(folder_id)
+
if filter_unread:
where_clauses.append(
"(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))",
diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue
index 6023cfe9..f3db2123 100644
--- a/meshchatx/src/frontend/components/TutorialModal.vue
+++ b/meshchatx/src/frontend/components/TutorialModal.vue
@@ -180,7 +180,161 @@
</p>
</div>
- <div class="space-y-4">
+ <div v-if="discoveryOption === null" class="flex flex-col items-center gap-6 py-4">
+ <div
+ class="bg-blue-500/10 dark:bg-blue-500/20 p-6 rounded-[2rem] text-center space-y-4 border border-blue-500/20 max-w-md"
+ >
+ <v-icon icon="mdi-account-search" color="blue" size="48"></v-icon>
+ <div class="text-lg font-bold text-gray-900 dark:text-white">
+ {{
+ $t("tutorial.discovery_question") ||
+ "Do you want to use community interface discovering and auto-connect?"
+ }}
+ </div>
+ <p class="text-sm text-gray-600 dark:text-zinc-400">
+ {{
+ $t("tutorial.discovery_desc") ||
+ "This allows MeshChatX to automatically find and connect to public community nodes near you or on the internet."
+ }}
+ </p>
+ <div class="flex gap-3 justify-center pt-2">
+ <button
+ type="button"
+ class="px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-bold shadow-lg transition-all"
+ :disabled="savingDiscovery"
+ @click="useDiscovery"
+ >
+ <v-progress-circular
+ v-if="savingDiscovery"
+ indeterminate
+ size="16"
+ width="2"
+ class="mr-2"
+ ></v-progress-circular>
+ {{ $t("tutorial.yes") || "Yes, use discovery" }}
+ </button>
+ <button
+ type="button"
+ class="px-6 py-2 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-bold shadow-sm transition-all"
+ @click="discoveryOption = 'no'"
+ >
+ {{ $t("tutorial.no") || "No, manual setup" }}
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div v-else class="space-y-4">
+ <!-- Discovered Interfaces (if any) -->
+ <div
+ v-if="sortedDiscoveredInterfaces.length > 0"
+ class="bg-emerald-500/5 dark:bg-emerald-500/10 rounded-3xl p-4 border border-emerald-500/20"
+ >
+ <div class="flex items-center gap-2 mb-3 px-1 text-sm">
+ <v-icon icon="mdi-radar" color="emerald"></v-icon>
+ <span class="font-bold text-gray-900 dark:text-white">Discovered Interfaces</span>
+ </div>
+ <div class="space-y-3 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
+ <div
+ v-for="iface in sortedDiscoveredInterfaces"
+ :key="iface.discovery_hash || iface.name"
+ class="interface-card group !p-3 transition-all duration-300"
+ >
+ <div class="flex gap-3 items-start relative">
+ <div class="interface-card__icon !w-10 !h-10 !rounded-xl shrink-0">
+ <MaterialDesignIcon
+ :icon-name="getDiscoveryIcon(iface)"
+ class="w-5 h-5"
+ />
+ </div>
+
+ <div class="flex-1 min-w-0 space-y-1">
+ <div class="flex items-center gap-2 flex-nowrap min-w-0">
+ <div
+ class="text-sm font-bold text-gray-900 dark:text-white truncate min-w-0"
+ >
+ {{ iface.name }}
+ </div>
+ <span class="type-chip !text-[9px] !px-1.5 shrink-0">{{
+ iface.type
+ }}</span>
+ </div>
+
+ <div class="flex items-center gap-2 flex-wrap">
+ <span
+ v-if="iface.value"
+ class="text-[9px] font-bold text-blue-600 dark:text-blue-400 shrink-0"
+ >
+ Stamps: {{ iface.value }}
+ </span>
+ </div>
+
+ <div class="flex flex-wrap gap-1.5 text-[10px] text-gray-500">
+ <span>Hops: {{ iface.hops }}</span>
+ <span class="capitalize shrink-0">{{ iface.status }}</span>
+ <span v-if="iface.last_heard" class="shrink-0">
+ {{ formatLastHeard(iface.last_heard) }}
+ </span>
+ </div>
+
+ <div
+ class="flex flex-col gap-0.5 pt-1 text-[9px] text-gray-400 dark:text-zinc-500 min-w-0"
+ >
+ <div
+ v-if="iface.reachable_on"
+ class="flex items-center gap-1.5 hover:text-blue-500 cursor-pointer min-w-0"
+ @click="
+ copyToClipboard(
+ `${iface.reachable_on}:${iface.port}`,
+ 'Address'
+ )
+ "
+ >
+ <MaterialDesignIcon
+ icon-name="link-variant"
+ class="w-3 h-3 shrink-0"
+ />
+ <span class="truncate"
+ >Address: {{ iface.reachable_on }}:{{ iface.port }}</span
+ >
+ </div>
+ <div
+ v-if="iface.transport_id"
+ class="flex items-center gap-1.5 hover:text-blue-500 cursor-pointer min-w-0"
+ @click="copyToClipboard(iface.transport_id, 'Transport ID')"
+ >
+ <MaterialDesignIcon
+ icon-name="identifier"
+ class="w-3 h-3 shrink-0"
+ />
+ <span class="truncate font-mono"
+ >Transport ID: {{ iface.transport_id }}</span
+ >
+ </div>
+ <div
+ v-if="iface.network_id"
+ class="flex items-center gap-1.5 hover:text-blue-500 cursor-pointer min-w-0"
+ @click="copyToClipboard(iface.network_id, 'Network ID')"
+ >
+ <MaterialDesignIcon icon-name="lan" class="w-3 h-3 shrink-0" />
+ <span class="truncate font-mono"
+ >Network ID: {{ iface.network_id }}</span
+ >
+ </div>
+ </div>
+ </div>
+
+ <div class="flex items-center gap-1 shrink-0">
+ <span
+ class="text-[8px] bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 rounded-full font-bold uppercase tracking-wider"
+ >Heard</span
+ >
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
<!-- Community Interfaces -->
<div
class="bg-gray-50 dark:bg-zinc-900 rounded-3xl p-3 border border-gray-100 dark:border-zinc-800"
@@ -232,7 +386,10 @@
</div>
</div>
- <div class="flex flex-col items-center gap-3 text-sm text-gray-900 dark:text-white">
+ <div
+ v-if="discoveryOption !== null"
+ class="flex flex-col items-center gap-3 text-sm text-gray-900 dark:text-white"
+ >
<p class="max-w-sm text-center">
{{ $t("tutorial.custom_interfaces_desc") }}
</p>
@@ -479,7 +636,7 @@
</div>
<div class="flex-1 overflow-y-auto px-6 md:px-12 py-10">
- <div class="max-w-4xl mx-auto h-full flex flex-col justify-between">
+ <div class="w-full h-full flex flex-col justify-between">
<transition name="fade-slide" mode="out-in">
<!-- Step 1: Welcome -->
<div
@@ -626,69 +783,284 @@
</p>
</div>
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
- <!-- Community Interfaces -->
+ <div v-if="discoveryOption === null" class="flex flex-col items-center gap-8 py-12">
<div
- class="bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-5 border border-gray-100 dark:border-zinc-800"
+ class="bg-blue-500/10 dark:bg-blue-500/20 p-12 rounded-[3rem] text-center space-y-6 border border-blue-500/20 max-w-2xl shadow-2xl"
>
- <div class="flex items-center gap-2 mb-5">
- <v-icon icon="mdi-web" color="blue" size="26"></v-icon>
- <span class="text-lg font-bold text-gray-900 dark:text-white">{{
- $t("tutorial.suggested_relays")
- }}</span>
+ <v-icon icon="mdi-account-search" color="blue" size="80"></v-icon>
+ <div class="text-3xl font-black text-gray-900 dark:text-white">
+ {{
+ $t("tutorial.discovery_question") ||
+ "Do you want to use community interface discovering and auto-connect?"
+ }}
+ </div>
+ <p class="text-xl text-gray-600 dark:text-zinc-400">
+ {{
+ $t("tutorial.discovery_desc") ||
+ "This allows MeshChatX to automatically find and connect to public community nodes near you or on the internet."
+ }}
+ </p>
+ <div class="flex gap-6 justify-center pt-4">
+ <button
+ type="button"
+ class="px-10 py-4 text-xl rounded-2xl bg-blue-600 hover:bg-blue-500 text-white font-black shadow-xl transition-all transform hover:scale-105"
+ :disabled="savingDiscovery"
+ @click="useDiscovery"
+ >
+ <v-progress-circular
+ v-if="savingDiscovery"
+ indeterminate
+ size="24"
+ width="3"
+ class="mr-3"
+ ></v-progress-circular>
+ {{ $t("tutorial.yes") || "Yes, use discovery" }}
+ </button>
+ <button
+ type="button"
+ class="px-10 py-4 text-xl rounded-2xl border-2 border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-black shadow-lg transition-all transform hover:scale-105"
+ @click="discoveryOption = 'no'"
+ >
+ {{ $t("tutorial.no") || "No, manual setup" }}
+ </button>
</div>
- <div class="space-y-3 max-h-[320px] overflow-y-auto pr-3 custom-scrollbar">
+ </div>
+ </div>
+
+ <div v-else class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
+ <!-- Left/Middle Columns: Discovered & Community -->
+ <div class="lg:col-span-1 xl:col-span-2 space-y-6">
+ <div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
+ <!-- Discovered Interfaces -->
<div
- v-for="iface in communityInterfaces"
- :key="iface.name"
- class="flex items-center justify-between p-3 bg-white dark:bg-zinc-800 rounded-xl border border-gray-100 dark:border-zinc-700 hover:border-blue-400 transition-all cursor-pointer"
- @click="selectCommunityInterface(iface)"
+ v-if="sortedDiscoveredInterfaces.length > 0"
+ class="bg-emerald-500/5 dark:bg-emerald-500/10 rounded-[1.5rem] p-5 border border-emerald-500/20 h-fit"
>
- <div class="flex flex-col">
- <span class="font-bold text-gray-900 dark:text-white text-base">
- {{ iface.name }}
- </span>
- <span class="text-xs text-gray-500 font-mono"
- >{{ iface.target_host }}:{{ iface.target_port }}</span
- >
- </div>
- <div class="flex items-center gap-2">
- <span
- v-if="iface.online"
- class="flex items-center gap-1.5 text-[9px] font-bold text-green-500 uppercase tracking-[0.2em]"
- >
- <span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
- {{ $t("tutorial.online") }}
- </span>
+ <div class="flex items-center justify-between mb-5">
+ <div class="flex items-center gap-2">
+ <v-icon icon="mdi-radar" color="emerald" size="26"></v-icon>
+ <span class="text-lg font-bold text-gray-900 dark:text-white"
+ >Discovered</span
+ >
+ </div>
<button
+ v-if="interfacesWithLocation.length > 0"
type="button"
- class="px-4 py-1 text-[11px] rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-sm transition-all"
- @click.stop="selectCommunityInterface(iface)"
+ class="px-2 py-1 text-[9px] rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-bold border border-emerald-500/20 hover:bg-emerald-500/20 transition-all"
+ @click="mapAllDiscovered"
>
- {{ $t("tutorial.use") }}
+ Map All ({{ interfacesWithLocation.length }})
</button>
</div>
+ <div class="space-y-3 max-h-[600px] overflow-y-auto pr-3 custom-scrollbar">
+ <div
+ v-for="iface in sortedDiscoveredInterfaces"
+ :key="iface.discovery_hash || iface.name"
+ class="interface-card group !p-4 transition-all duration-300"
+ >
+ <div class="flex gap-4 items-start relative">
+ <div class="interface-card__icon !w-12 !h-12 !rounded-2xl shrink-0">
+ <MaterialDesignIcon
+ :icon-name="getDiscoveryIcon(iface)"
+ class="w-6 h-6"
+ />
+ </div>
+
+ <div class="flex-1 min-w-0 space-y-2">
+ <div class="flex items-center gap-2 flex-nowrap min-w-0">
+ <div
+ class="text-lg font-bold text-gray-900 dark:text-white truncate min-w-0"
+ >
+ {{ iface.name }}
+ </div>
+ <span class="type-chip shrink-0">{{ iface.type }}</span>
+ </div>
+
+ <div class="flex items-center gap-2 flex-wrap">
+ <span
+ v-if="iface.value"
+ class="text-xs font-bold text-blue-600 dark:text-blue-400 shrink-0"
+ >
+ Stamps: {{ iface.value }}
+ </span>
+ </div>
+
+ <div class="flex flex-wrap gap-2 text-xs text-gray-500">
+ <span class="stat-chip !px-2 !py-0.5"
+ >Hops: {{ iface.hops }}</span
+ >
+ <span class="stat-chip !px-2 !py-0.5 capitalize shrink-0">{{
+ iface.status
+ }}</span>
+ <span
+ v-if="iface.last_heard"
+ class="stat-chip !px-2 !py-0.5 shrink-0"
+ >
+ {{ formatLastHeard(iface.last_heard) }}
+ </span>
+ </div>
+
+ <div
+ class="grid gap-1.5 pt-1 text-[11px] text-gray-500 dark:text-zinc-400 min-w-0"
+ >
+ <div
+ v-if="iface.reachable_on"
+ class="flex items-center gap-2 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
+ @click="
+ copyToClipboard(
+ `${iface.reachable_on}:${iface.port}`,
+ 'Address'
+ )
+ "
+ >
+ <MaterialDesignIcon
+ icon-name="link-variant"
+ class="w-4 h-4 shrink-0"
+ />
+ <span class="truncate"
+ >Address: {{ iface.reachable_on }}:{{
+ iface.port
+ }}</span
+ >
+ </div>
+
+ <div
+ v-if="iface.transport_id"
+ class="flex items-center gap-2 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
+ @click="
+ copyToClipboard(iface.transport_id, 'Transport ID')
+ "
+ >
+ <MaterialDesignIcon
+ icon-name="identifier"
+ class="w-4 h-4 shrink-0"
+ />
+ <span class="truncate font-mono"
+ >Transport ID: {{ iface.transport_id }}</span
+ >
+ </div>
+
+ <div
+ v-if="iface.network_id"
+ class="flex items-center gap-2 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
+ @click="copyToClipboard(iface.network_id, 'Network ID')"
+ >
+ <MaterialDesignIcon
+ icon-name="lan"
+ class="w-4 h-4 shrink-0"
+ />
+ <span class="truncate font-mono"
+ >Network ID: {{ iface.network_id }}</span
+ >
+ </div>
+
+ <div
+ v-if="iface.latitude != null && iface.longitude != null"
+ class="flex items-center gap-2 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
+ @click="
+ copyToClipboard(
+ `${iface.latitude}, ${iface.longitude}`,
+ 'Location'
+ )
+ "
+ >
+ <MaterialDesignIcon
+ icon-name="map-marker"
+ class="w-4 h-4 shrink-0"
+ />
+ <span class="truncate"
+ >Loc: {{ iface.latitude }},
+ {{ iface.longitude }}</span
+ >
+ </div>
+ </div>
+ </div>
+
+ <div class="flex items-center gap-2 shrink-0">
+ <span
+ class="text-[10px] bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider"
+ >Heard</span
+ >
+ </div>
+ </div>
+ </div>
+ </div>
</div>
- <div v-if="loadingInterfaces" class="flex justify-center py-4">
- <v-progress-circular indeterminate color="blue" size="32"></v-progress-circular>
+
+ <!-- Community Interfaces -->
+ <div
+ class="bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-5 border border-gray-100 dark:border-zinc-800 h-fit"
+ >
+ <div class="flex items-center gap-2 mb-5">
+ <v-icon icon="mdi-web" color="blue" size="26"></v-icon>
+ <span class="text-lg font-bold text-gray-900 dark:text-white">{{
+ $t("tutorial.suggested_relays")
+ }}</span>
+ </div>
+ <div class="space-y-3 max-h-[600px] overflow-y-auto pr-3 custom-scrollbar">
+ <div
+ v-for="iface in communityInterfaces"
+ :key="iface.name"
+ class="flex items-center justify-between p-3 bg-white dark:bg-zinc-800 rounded-xl border border-gray-100 dark:border-zinc-700 hover:border-blue-400 transition-all cursor-pointer"
+ @click="selectCommunityInterface(iface)"
+ >
+ <div class="flex flex-col min-w-0">
+ <span
+ class="font-bold text-gray-900 dark:text-white text-base truncate"
+ >
+ {{ iface.name }}
+ </span>
+ <span class="text-xs text-gray-500 font-mono truncate"
+ >{{ iface.target_host }}:{{ iface.target_port }}</span
+ >
+ </div>
+ <div class="flex items-center gap-2 shrink-0">
+ <span
+ v-if="iface.online"
+ class="flex items-center gap-1.5 text-[9px] font-bold text-green-500 uppercase tracking-[0.2em]"
+ >
+ <span
+ class="w-2 h-2 rounded-full bg-green-500 animate-pulse"
+ ></span>
+ {{ $t("tutorial.online") }}
+ </span>
+ <button
+ type="button"
+ class="px-4 py-1 text-[11px] rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-sm transition-all"
+ @click.stop="selectCommunityInterface(iface)"
+ >
+ {{ $t("tutorial.use") }}
+ </button>
+ </div>
+ </div>
+ <div v-if="loadingInterfaces" class="flex justify-center py-4">
+ <v-progress-circular
+ indeterminate
+ color="blue"
+ size="32"
+ ></v-progress-circular>
+ </div>
+ </div>
</div>
</div>
</div>
+ <!-- Right Column: Manual Setup -->
<div
- class="flex flex-col justify-center gap-4 text-sm text-gray-900 dark:text-white bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-5 border border-gray-100 dark:border-zinc-800"
+ class="flex flex-col justify-center gap-4 text-sm text-gray-900 dark:text-white bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-8 border border-gray-100 dark:border-zinc-800 h-fit my-auto"
>
- <div class="text-center">
- <p class="text-base font-bold text-gray-900 dark:text-white">
+ <div class="text-center space-y-4">
+ <v-icon icon="mdi-plus-circle-outline" size="48" color="blue"></v-icon>
+ <p class="text-xl font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.custom_interfaces") }}
</p>
- <p class="mt-2">
+ <p class="text-gray-600 dark:text-zinc-400">
{{ $t("tutorial.custom_interfaces_desc_page") }}
</p>
</div>
<button
type="button"
- class="px-4 py-2 text-[11px] rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-sm transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
+ class="mt-4 px-6 py-3 text-sm rounded-xl border-2 border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-bold shadow-sm transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
@click="gotoAddInterface"
>
{{ $t("tutorial.open_interfaces") }}
@@ -925,6 +1297,12 @@ export default {
communityInterfaces: [],
loadingInterfaces: false,
interfaceAddedViaTutorial: false,
+ discoveryOption: null,
+ discoveredInterfaces: [],
+ discoveredActive: [],
+ loadingDiscovered: false,
+ savingDiscovery: false,
+ discoveryInterval: null,
};
},
computed: {
@@ -937,10 +1315,25 @@ export default {
config() {
return GlobalState.config;
},
+ sortedDiscoveredInterfaces() {
+ return [...this.discoveredInterfaces].sort((a, b) => (b.last_heard || 0) - (a.last_heard || 0));
+ },
+ interfacesWithLocation() {
+ return this.discoveredInterfaces.filter((iface) => iface.latitude != null && iface.longitude != null);
+ },
+ },
+ beforeUnmount() {
+ if (this.discoveryInterval) {
+ clearInterval(this.discoveryInterval);
+ }
},
mounted() {
if (this.isPage) {
this.loadCommunityInterfaces();
+ this.loadDiscoveredInterfaces();
+ this.discoveryInterval = setInterval(() => {
+ this.loadDiscoveredInterfaces();
+ }, 5000);
}
},
methods: {
@@ -970,7 +1363,16 @@ export default {
this.visible = true;
this.currentStep = 1;
this.interfaceAddedViaTutorial = false;
+ this.discoveryOption = null;
await this.loadCommunityInterfaces();
+ await this.loadDiscoveredInterfaces();
+
+ if (this.discoveryInterval) {
+ clearInterval(this.discoveryInterval);
+ }
+ this.discoveryInterval = setInterval(() => {
+ this.loadDiscoveredInterfaces();
+ }, 5000);
},
async loadCommunityInterfaces() {
this.loadingInterfaces = true;
@@ -983,6 +1385,85 @@ export default {
this.loadingInterfaces = false;
}
},
+ async loadDiscoveredInterfaces() {
+ this.loadingDiscovered = true;
+ try {
+ const response = await window.axios.get(`/api/v1/reticulum/discovered-interfaces`);
+ this.discoveredInterfaces = response.data?.interfaces ?? [];
+ this.discoveredActive = response.data?.active ?? [];
+ } catch (e) {
+ console.error("Failed to load discovered interfaces:", e);
+ } finally {
+ this.loadingDiscovered = false;
+ }
+ },
+ async useDiscovery() {
+ this.savingDiscovery = true;
+ try {
+ const payload = {
+ discover_interfaces: true,
+ autoconnect_discovered_interfaces: 3, // default to 3 slots
+ };
+ await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
+ ToastUtils.success("Community discovery enabled");
+ this.discoveryOption = "yes";
+ this.nextStep();
+ } catch (e) {
+ console.error("Failed to enable discovery:", e);
+ ToastUtils.error("Failed to enable discovery");
+ } finally {
+ this.savingDiscovery = false;
+ }
+ },
+ getDiscoveryIcon(iface) {
+ switch (iface.type) {
+ case "AutoInterface":
+ return "home-automation";
+ case "RNodeInterface":
+ return iface.port && iface.port.toString().startsWith("tcp://") ? "lan-connect" : "radio-tower";
+ case "RNodeMultiInterface":
+ return "access-point-network";
+ case "TCPClientInterface":
+ case "BackboneInterface":
+ return "lan-connect";
+ case "TCPServerInterface":
+ return "lan";
+ case "UDPInterface":
+ return "wan";
+ case "SerialInterface":
+ return "usb-port";
+ case "KISSInterface":
+ case "AX25KISSInterface":
+ return "antenna";
+ case "I2PInterface":
+ return "eye";
+ case "PipeInterface":
+ return "pipe";
+ default:
+ return "server-network";
+ }
+ },
+ formatLastHeard(ts) {
+ const seconds = Math.max(0, Math.floor(Date.now() / 1000 - ts));
+ if (seconds < 60) return `${seconds}s ago`;
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
+ return `${Math.floor(seconds / 86400)}d ago`;
+ },
+ copyToClipboard(text, label) {
+ if (!text) return;
+ navigator.clipboard.writeText(text);
+ ToastUtils.success(`${label} copied to clipboard`);
+ },
+ mapAllDiscovered() {
+ if (!this.isPage) {
+ this.visible = false;
+ }
+ this.$router.push({
+ name: "map",
+ query: { view: "discovered" },
+ });
+ },
async selectCommunityInterface(iface) {
try {
await window.axios.post("/api/v1/reticulum/interfaces/add", {
diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index 8014e519..9e28bb4d 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -264,7 +264,27 @@
class="flex items-center gap-5 pl-5 border-l-2 border-zinc-100 dark:border-zinc-800 ml-6 relative"
>
<div
- class="absolute -left-[2px] top-0 bottom-0 w-[2px] bg-gradient-to-b from-blue-500 to-purple-500"
+ class="absolute -left-[2px] top-0 bottom-0 w-[2px] bg-gradient-to-b from-blue-500 to-emerald-500"
+ ></div>
+ <div
+ class="w-12 h-12 rounded-2xl bg-emerald-500/10 flex items-center justify-center border border-emerald-500/20 text-emerald-600 font-black text-[10px] tracking-tighter shadow-sm"
+ >
+ LXMFy
+ </div>
+ <div>
+ <div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
+ LXMF Bot framework
+ </div>
+ <div class="text-xs font-mono font-bold text-gray-400 mt-1">
+ v{{ (appInfo.dependencies && appInfo.dependencies.lxmfy) || "unknown" }}
+ </div>
+ </div>
+ </div>
+ <div
+ class="flex items-center gap-5 pl-5 border-l-2 border-zinc-100 dark:border-zinc-800 ml-6 relative"
+ >
+ <div
+ class="absolute -left-[2px] top-0 bottom-0 w-[2px] bg-gradient-to-b from-emerald-500 to-purple-500"
></div>
<div
class="w-12 h-12 rounded-2xl bg-purple-500/10 flex items-center justify-center border border-purple-500/20 text-purple-600 font-black text-[10px] tracking-tighter shadow-sm"
@@ -295,8 +315,24 @@
<div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
Reticulum Network Stack
</div>
- <div class="text-xs font-mono font-bold text-gray-400 mt-1">
- v{{ appInfo.rns_version }}
+ <div class="flex items-center gap-2 mt-1">
+ <div class="text-xs font-mono font-bold text-gray-400">
+ v{{ appInfo.rns_version }}
+ </div>
+ <div
+ :class="[
+ appInfo.is_connected_to_shared_instance
+ ? 'bg-blue-500/10 text-blue-500 border-blue-500/20'
+ : 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
+ ]"
+ class="text-[8px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded border"
+ >
+ {{
+ appInfo.is_connected_to_shared_instance
+ ? "Shared Instance"
+ : "Main Instance"
+ }}
+ </div>
</div>
</div>
</div>
@@ -530,34 +566,75 @@
</div>
</div>
- <div v-if="snapshots.length > 0" class="grid gap-3 sm:grid-cols-2">
+ <div v-if="snapshots && snapshots.length > 0" class="space-y-4">
+ <div class="grid gap-3 sm:grid-cols-2">
+ <div
+ v-for="snapshot in snapshots"
+ :key="snapshot.path"
+ class="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 hover:border-purple-500/20 transition-all group"
+ >
+ <div class="flex flex-col min-w-0">
+ <span
+ class="font-black text-gray-900 dark:text-white text-xs truncate"
+ >{{ snapshot.name }}</span
+ >
+ <span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
+ >{{ formatBytes(snapshot.size) }} •
+ {{ Utils.formatTimeAgo(snapshot.created_at) }}</span
+ >
+ </div>
+ <div
+ class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
+ >
+ <button
+ type="button"
+ class="secondary-chip !px-3 !py-1 !text-[10px]"
+ @click="restoreFromSnapshot(snapshot.path)"
+ >
+ Restore
+ </button>
+ <button
+ type="button"
+ class="danger-chip !px-3 !py-1 !text-[10px]"
+ @click="deleteSnapshot(snapshot.name)"
+ >
+ <v-icon icon="mdi-delete" size="12"></v-icon>
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Snapshots Pagination -->
<div
- v-for="snapshot in snapshots"
- :key="snapshot.path"
- class="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 hover:border-purple-500/20 transition-all group"
+ v-if="snapshotsTotal > snapshotsLimit"
+ class="flex items-center justify-between px-2"
>
- <div class="flex flex-col">
- <span
- class="font-black text-gray-900 dark:text-white text-xs truncate max-w-[150px]"
- >{{ snapshot.name }}</span
+ <div class="text-[10px] font-black text-gray-400 uppercase tracking-widest">
+ Page {{ Math.floor(snapshotsOffset / snapshotsLimit) + 1 }} of
+ {{ Math.ceil(snapshotsTotal / snapshotsLimit) }}
+ </div>
+ <div class="flex gap-2">
+ <button
+ class="secondary-chip !p-1 disabled:opacity-30"
+ :disabled="snapshotsOffset === 0"
+ @click="prevSnapshots"
>
- <span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
- >{{ formatBytes(snapshot.size) }} • {{ snapshot.created_at }}</span
+ <v-icon icon="mdi-chevron-left"></v-icon>
+ </button>
+ <button
+ class="secondary-chip !p-1 disabled:opacity-30"
+ :disabled="snapshotsOffset + snapshotsLimit >= snapshotsTotal"
+ @click="nextSnapshots"
>
+ <v-icon icon="mdi-chevron-right"></v-icon>
+ </button>
</div>
- <button
- type="button"
- class="secondary-chip !px-3 !py-1 !text-[10px] opacity-0 group-hover:opacity-100"
- @click="restoreFromSnapshot(snapshot.path)"
- >
- Restore
- </button>
</div>
</div>
</div>
<!-- Auto Backups -->
- <div v-if="autoBackups.length > 0" class="space-y-6">
+ <div v-if="autoBackups && autoBackups.length > 0" class="space-y-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="space-y-1">
<div
@@ -572,28 +649,69 @@
</div>
</div>
- <div class="grid gap-3 sm:grid-cols-2">
+ <div v-if="autoBackups && autoBackups.length > 0" class="space-y-4">
+ <div class="grid gap-3 sm:grid-cols-2">
+ <div
+ v-for="backup in autoBackups"
+ :key="backup.path"
+ class="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 hover:border-blue-500/20 transition-all group"
+ >
+ <div class="flex flex-col min-w-0">
+ <span
+ class="font-black text-gray-900 dark:text-white text-xs truncate"
+ >{{ backup.name }}</span
+ >
+ <span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
+ >{{ formatBytes(backup.size) }} •
+ {{ Utils.formatTimeAgo(backup.created_at) }}</span
+ >
+ </div>
+ <div
+ class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
+ >
+ <button
+ type="button"
+ class="secondary-chip !px-3 !py-1 !text-[10px]"
+ @click="restoreFromSnapshot(backup.path)"
+ >
+ Restore
+ </button>
+ <button
+ type="button"
+ class="danger-chip !px-3 !py-1 !text-[10px]"
+ @click="deleteBackup(backup.name)"
+ >
+ <v-icon icon="mdi-delete" size="12"></v-icon>
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Backups Pagination -->
<div
- v-for="backup in autoBackups"
- :key="backup.path"
- class="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 hover:border-blue-500/20 transition-all group"
+ v-if="autoBackupsTotal > autoBackupsLimit"
+ class="flex items-center justify-between px-2"
>
- <div class="flex flex-col">
- <span
- class="font-black text-gray-900 dark:text-white text-xs truncate max-w-[150px]"
- >{{ backup.name }}</span
+ <div class="text-[10px] font-black text-gray-400 uppercase tracking-widest">
+ Page {{ Math.floor(autoBackupsOffset / autoBackupsLimit) + 1 }} of
+ {{ Math.ceil(autoBackupsTotal / autoBackupsLimit) }}
+ </div>
+ <div class="flex gap-2">
+ <button
+ class="secondary-chip !p-1 disabled:opacity-30"
+ :disabled="autoBackupsOffset === 0"
+ @click="prevBackups"
>
- <span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
- >{{ formatBytes(backup.size) }} • {{ backup.created_at }}</span
+ <v-icon icon="mdi-chevron-left"></v-icon>
+ </button>
+ <button
+ class="secondary-chip !p-1 disabled:opacity-30"
+ :disabled="autoBackupsOffset + autoBackupsLimit >= autoBackupsTotal"
+ @click="nextBackups"
>
+ <v-icon icon="mdi-chevron-right"></v-icon>
+ </button>
</div>
- <button
- type="button"
- class="secondary-chip !px-3 !py-1 !text-[10px] opacity-0 group-hover:opacity-100"
- @click="restoreFromSnapshot(backup.path)"
- >
- Restore
- </button>
</div>
</div>
</div>
@@ -704,6 +822,7 @@ export default {
components: {},
data() {
return {
+ Utils,
appInfo: null,
config: null,
updateInterval: null,
@@ -725,10 +844,16 @@ export default {
restoreFile: null,
snapshotName: "",
snapshots: [],
+ snapshotsTotal: 0,
+ snapshotsOffset: 0,
+ snapshotsLimit: 3,
snapshotInProgress: false,
snapshotMessage: "",
snapshotError: "",
autoBackups: [],
+ autoBackupsTotal: 0,
+ autoBackupsOffset: 0,
+ autoBackupsLimit: 3,
identityBackupMessage: "",
identityBackupError: "",
identityBase32: "",
@@ -776,18 +901,74 @@ export default {
methods: {
async listSnapshots() {
try {
- const response = await window.axios.get("/api/v1/database/snapshots");
- this.snapshots = response.data;
+ const response = await window.axios.get("/api/v1/database/snapshots", {
+ params: {
+ limit: this.snapshotsLimit,
+ offset: this.snapshotsOffset,
+ },
+ });
+ this.snapshots = response.data.snapshots;
+ this.snapshotsTotal = response.data.total;
} catch (e) {
console.log("Failed to list snapshots", e);
}
},
async listAutoBackups() {
try {
- const response = await window.axios.get("/api/v1/database/backups");
- this.autoBackups = response.data;
- } catch (e) {
- console.log("Failed to list auto-backups", e);
+ const response = await window.axios.get("/api/v1/database/backups", {
+ params: {
+ limit: this.autoBackupsLimit,
+ offset: this.autoBackupsOffset,
+ },
+ });
+ this.autoBackups = response.data.backups;
+ this.autoBackupsTotal = response.data.total;
+ } catch {
+ console.log("Failed to list auto-backups");
+ }
+ },
+ async deleteSnapshot(filename) {
+ if (!(await DialogUtils.confirm("Are you sure you want to delete this snapshot?"))) return;
+ try {
+ await window.axios.delete(`/api/v1/database/snapshots/${filename}`);
+ ToastUtils.success("Snapshot deleted");
+ await this.listSnapshots();
+ } catch {
+ ToastUtils.error("Failed to delete snapshot");
+ }
+ },
+ async deleteBackup(filename) {
+ if (!(await DialogUtils.confirm("Are you sure you want to delete this backup?"))) return;
+ try {
+ await window.axios.delete(`/api/v1/database/backups/${filename}`);
+ ToastUtils.success("Backup deleted");
+ await this.listAutoBackups();
+ } catch {
+ ToastUtils.error("Failed to delete backup");
+ }
+ },
+ async nextSnapshots() {
+ if (this.snapshotsOffset + this.snapshotsLimit < this.snapshotsTotal) {
+ this.snapshotsOffset += this.snapshotsLimit;
+ await this.listSnapshots();
+ }
+ },
+ async prevSnapshots() {
+ if (this.snapshotsOffset > 0) {
+ this.snapshotsOffset = Math.max(0, this.snapshotsOffset - this.snapshotsLimit);
+ await this.listSnapshots();
+ }
+ },
+ async nextBackups() {
+ if (this.autoBackupsOffset + this.autoBackupsLimit < this.autoBackupsTotal) {
+ this.autoBackupsOffset += this.autoBackupsLimit;
+ await this.listAutoBackups();
+ }
+ },
+ async prevBackups() {
+ if (this.autoBackupsOffset > 0) {
+ this.autoBackupsOffset = Math.max(0, this.autoBackupsOffset - this.autoBackupsLimit);
+ await this.listAutoBackups();
}
},
async createSnapshot() {
@@ -802,9 +983,8 @@ export default {
this.snapshotMessage = "Snapshot created successfully";
this.snapshotName = "";
await this.listSnapshots();
- } catch (e) {
+ } catch {
this.snapshotError = "Failed to create snapshot";
- console.log(e);
} finally {
this.snapshotInProgress = false;
}
@@ -825,9 +1005,8 @@ export default {
setTimeout(() => ElectronUtils.relaunch(), 2000);
}
}
- } catch (e) {
+ } catch {
ToastUtils.error("Failed to restore snapshot");
- console.log(e);
}
},
async getAppInfo() {
@@ -856,9 +1035,8 @@ export default {
await window.axios.post("/api/v1/app/integrity/acknowledge");
ToastUtils.success("Integrity issues acknowledged");
await this.getAppInfo();
- } catch (e) {
+ } catch {
ToastUtils.error("Failed to acknowledge integrity issues");
- console.log(e);
}
}
},
@@ -1075,9 +1253,9 @@ export default {
link.remove();
window.URL.revokeObjectURL(url);
this.identityBackupMessage = "Identity downloaded. Keep it secret.";
- } catch (e) {
+ ToastUtils.success("Identity key file exported");
+ } catch {
this.identityBackupError = "Failed to download identity";
- console.log(e);
}
},
async copyIdentityBase32() {
@@ -1092,9 +1270,9 @@ export default {
}
await navigator.clipboard.writeText(this.identityBase32);
this.identityBase32Message = "Identity copied. Clear your clipboard after use.";
- } catch (e) {
+ ToastUtils.success("Identity Base32 key copied to clipboard");
+ } catch {
this.identityBase32Error = "Failed to copy identity";
- console.log(e);
}
},
onIdentityRestoreFileChange(event) {
@@ -1124,9 +1302,8 @@ export default {
headers: { "Content-Type": "multipart/form-data" },
});
this.identityRestoreMessage = response.data.message || "Identity imported.";
- } catch (e) {
+ } catch {
this.identityRestoreError = "Identity restore failed";
- console.log(e);
} finally {
this.identityRestoreInProgress = false;
}
@@ -1147,9 +1324,8 @@ export default {
base32: this.identityRestoreBase32.trim(),
});
this.identityRestoreMessage = response.data.message || "Identity imported.";
- } catch (e) {
+ } catch {
this.identityRestoreError = "Identity restore failed";
- console.log(e);
} finally {
this.identityRestoreInProgress = false;
}
diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
index f2e67b3f..068a4e55 100644
--- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
+++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
@@ -3,7 +3,7 @@
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="flex-1 overflow-y-auto w-full">
- <div class="p-3 md:p-6 space-y-4 max-w-6xl mx-auto w-full flex-1">
+ <div class="p-3 md:p-6 space-y-4 w-full flex-1">
<div
v-if="showRestartReminder"
class="bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-3xl shadow-xl p-4 flex flex-wrap gap-3 items-center"
@@ -28,11 +28,11 @@
<div class="glass-card space-y-4">
<div class="flex flex-wrap gap-3 items-center">
- <div class="flex-1">
+ <div class="flex-1 min-w-0">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("interfaces.manage") }}
</div>
- <div class="text-xl font-semibold text-gray-900 dark:text-white">
+ <div class="text-xl font-semibold text-gray-900 dark:text-white truncate">
{{ $t("interfaces.title") }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
@@ -111,136 +111,370 @@
</div>
</div>
- <div
- v-if="filteredInterfaces.length === 0"
- class="glass-card text-center py-10 text-gray-500 dark:text-gray-300"
- >
- <MaterialDesignIcon icon-name="lan-disconnect" class="w-10 h-10 mx-auto mb-3" />
- <div class="text-lg font-semibold">{{ $t("interfaces.no_interfaces_found") }}</div>
- <div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
- </div>
-
<div class="glass-card space-y-4">
- <div class="flex flex-wrap gap-3 items-center">
- <div class="flex-1">
+ <div class="flex flex-wrap gap-2">
+ <button
+ v-for="tab in ['overview', 'discovery']"
+ :key="tab"
+ type="button"
+ :class="tabChipClass(activeTab === tab)"
+ @click="activeTab = tab"
+ >
+ <span v-if="tab === 'overview'">Overview</span>
+ <span v-else>Discovery Settings</span>
+ </button>
+ </div>
+
+ <div v-if="activeTab === 'overview'" class="space-y-4">
+ <div class="glass-card space-y-3">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
- Discovery
- </div>
- <div class="text-xl font-semibold text-gray-900 dark:text-white">Interface Discovery</div>
- <div class="text-sm text-gray-600 dark:text-gray-300">
- Publish your interfaces for others to find, or listen for announced entrypoints and
- auto-connect to them.
+ Configured
</div>
- </div>
- <RouterLink :to="{ name: 'interfaces.add' }" class="secondary-chip text-sm">
- <MaterialDesignIcon icon-name="lan" class="w-4 h-4" />
- Configure Per-Interface
- </RouterLink>
- </div>
- <div class="grid gap-4 md:grid-cols-2">
- <div class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
- <div class="font-semibold text-gray-900 dark:text-white">Publish (Server)</div>
- <div>
- Enable discovery while adding or editing an interface to broadcast reachable details.
- Reticulum will sign and stamp announces automatically.
+ <div class="text-xl font-semibold text-gray-900 dark:text-white">Interfaces</div>
+ <div
+ v-if="filteredInterfaces.length !== 0"
+ class="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3 3xl:grid-cols-4 4xl:grid-cols-5"
+ >
+ <Interface
+ v-for="iface of filteredInterfaces"
+ :key="iface._name"
+ :iface="iface"
+ :is-reticulum-running="isReticulumRunning"
+ @enable="enableInterface(iface._name)"
+ @disable="disableInterface(iface._name)"
+ @edit="editInterface(iface._name)"
+ @export="exportInterface(iface._name)"
+ @delete="deleteInterface(iface._name)"
+ />
</div>
- <div class="text-xs text-gray-500 dark:text-gray-400">
- Requires LXMF in the Python environment. Transport is optional for publishing, but
- usually recommended so peers can connect back.
+ <div v-else class="glass-card text-center py-10 text-gray-500 dark:text-gray-300">
+ <MaterialDesignIcon icon-name="lan-disconnect" class="w-10 h-10 mx-auto mb-3" />
+ <div class="text-lg font-semibold">{{ $t("interfaces.no_interfaces_found") }}</div>
+ <div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
</div>
</div>
- <div class="space-y-3">
- <div class="flex items-center">
- <div class="flex flex-col mr-auto">
- <div class="text-sm font-semibold text-gray-900 dark:text-white">
- Discover Interfaces (Peer)
+
+ <div class="glass-card space-y-3">
+ <div class="flex items-center gap-3">
+ <div class="flex-1">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ Discovered Interfaces
</div>
- <div class="text-xs text-gray-500 dark:text-gray-400">
- Listen for discovery announces and optionally auto-connect to available
- interfaces.
+ <div class="text-xl font-semibold text-gray-900 dark:text-white">
+ Recently Heard Announces
</div>
+ <div class="text-sm text-gray-600 dark:text-gray-300">
+ Cards appear/disappear as announces are heard. Connected entries show a green
+ pill; disconnected entries are dimmed with a red label.
+ </div>
+ </div>
+ <div class="flex gap-2">
+ <button
+ v-if="interfacesWithLocation.length > 0"
+ type="button"
+ class="secondary-chip text-xs bg-blue-500/10 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/30"
+ @click="mapAllDiscovered"
+ >
+ <MaterialDesignIcon icon-name="map-marker-multiple" class="w-4 h-4" />
+ Map All ({{ interfacesWithLocation.length }})
+ </button>
+ <button
+ type="button"
+ class="secondary-chip text-xs"
+ @click="loadDiscoveredInterfaces"
+ >
+ <MaterialDesignIcon icon-name="refresh" class="w-4 h-4" />
+ Refresh
+ </button>
</div>
- <Toggle v-model="discoveryConfig.discover_interfaces" class="my-auto mx-2" />
</div>
- <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
- <div>
- <div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
- Allowed Sources
+
+ <div
+ v-if="sortedDiscoveredInterfaces.length === 0"
+ class="text-sm text-gray-500 dark:text-gray-300"
+ >
+ No discovered interfaces yet.
+ </div>
+
+ <div
+ v-else
+ class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 4xl:grid-cols-6"
+ >
+ <div
+ v-for="iface in sortedDiscoveredInterfaces"
+ :key="iface.discovery_hash || iface.name"
+ class="interface-card group transition-all duration-300"
+ :class="{ 'opacity-70 grayscale-[0.3]': !isDiscoveredConnected(iface) }"
+ >
+ <div class="flex gap-4 items-start relative">
+ <!-- Disconnected Overlay -->
+ <div
+ v-if="!isDiscoveredConnected(iface)"
+ class="absolute inset-0 z-10 flex items-center justify-center bg-white/20 dark:bg-zinc-900/20 backdrop-blur-[0.5px] rounded-3xl pointer-events-none"
+ >
+ <div
+ class="bg-red-500/90 text-white px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider animate-pulse"
+ >
+ <MaterialDesignIcon icon-name="lan-disconnect" class="w-3.5 h-3.5" />
+ <span>{{ $t("app.disabled") }}</span>
+ </div>
+ </div>
+
+ <div class="interface-card__icon shrink-0">
+ <MaterialDesignIcon :icon-name="getDiscoveryIcon(iface)" class="w-6 h-6" />
+ </div>
+
+ <div class="flex-1 min-w-0 space-y-2">
+ <div class="flex items-center gap-2 flex-nowrap min-w-0">
+ <div
+ class="text-base sm:text-lg font-semibold text-gray-900 dark:text-white truncate min-w-0"
+ >
+ {{ iface.name }}
+ </div>
+ <span class="type-chip shrink-0">{{ iface.type }}</span>
+ </div>
+
+ <div class="flex items-center gap-2 flex-wrap">
+ <span
+ v-if="iface.value"
+ class="text-[10px] font-bold text-blue-600 dark:text-blue-400"
+ >
+ Stamps: {{ iface.value }}
+ </span>
+ <span
+ v-if="isDiscoveredConnected(iface)"
+ class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-700 px-2 py-0.5 text-[10px] font-semibold dark:bg-emerald-900/40 dark:text-emerald-200 shrink-0"
+ >
+ Connected
+ </span>
+ </div>
+
+ <div class="flex flex-wrap gap-1.5 text-[10px] sm:text-xs">
+ <span class="stat-chip bg-gray-50 dark:bg-zinc-800/50"
+ >Hops: {{ iface.hops }}</span
+ >
+ <span class="stat-chip capitalize bg-gray-50 dark:bg-zinc-800/50">{{
+ iface.status
+ }}</span>
+ <span
+ v-if="iface.last_heard"
+ class="stat-chip bg-gray-50 dark:bg-zinc-800/50"
+ >
+ Heard: {{ formatLastHeard(iface.last_heard) }}
+ </span>
+ </div>
+
+ <div class="grid gap-1.5 text-[10px] sm:text-[11px] pt-1 min-w-0">
+ <div
+ v-if="iface.reachable_on"
+ class="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
+ @click="
+ copyToClipboard(
+ `${iface.reachable_on}:${iface.port}`,
+ 'Address'
+ )
+ "
+ >
+ <MaterialDesignIcon
+ icon-name="link-variant"
+ class="w-3.5 h-3.5 shrink-0"
+ />
+ <span class="truncate"
+ >Address: {{ iface.reachable_on }}:{{ iface.port }}</span
+ >
+ </div>
+
+ <div
+ v-if="iface.transport_id"
+ class="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
+ @click="copyToClipboard(iface.transport_id, 'Transport ID')"
+ >
+ <MaterialDesignIcon
+ icon-name="identifier"
+ class="w-3.5 h-3.5 shrink-0"
+ />
+ <span class="truncate font-mono"
+ >Transport ID: {{ iface.transport_id }}</span
+ >
+ </div>
+
+ <div
+ v-if="iface.network_id"
+ class="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
+ @click="copyToClipboard(iface.network_id, 'Network ID')"
+ >
+ <MaterialDesignIcon icon-name="lan" class="w-3.5 h-3.5 shrink-0" />
+ <span class="truncate font-mono"
+ >Network ID: {{ iface.network_id }}</span
+ >
+ </div>
+
+ <div
+ v-if="iface.latitude != null && iface.longitude != null"
+ class="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
+ @click="
+ copyToClipboard(
+ `${iface.latitude}, ${iface.longitude}`,
+ 'Location'
+ )
+ "
+ >
+ <MaterialDesignIcon
+ icon-name="map-marker"
+ class="w-3.5 h-3.5 shrink-0"
+ />
+ <span class="truncate"
+ >Loc: {{ iface.latitude }}, {{ iface.longitude }}</span
+ >
+ </div>
+
+ <div
+ v-if="discoveredBytes(iface)"
+ class="flex items-center gap-2 text-gray-500 dark:text-gray-500 min-w-0"
+ >
+ <MaterialDesignIcon
+ icon-name="swap-vertical"
+ class="w-3.5 h-3.5 shrink-0"
+ />
+ <span class="truncate"
+ >TX {{ discoveredBytes(iface).tx }} · RX
+ {{ discoveredBytes(iface).rx }}</span
+ >
+ </div>
+ </div>
+ </div>
+
+ <div class="flex flex-col gap-2 shrink-0">
+ <button
+ v-if="iface.latitude != null && iface.longitude != null"
+ type="button"
+ class="secondary-chip !p-2 !rounded-xl"
+ :title="$t('map.title')"
+ @click="goToMap(iface)"
+ >
+ <MaterialDesignIcon icon-name="map" class="w-4 h-4" />
+ </button>
+ </div>
</div>
- <input
- v-model="discoveryConfig.interface_discovery_sources"
- type="text"
- placeholder="Comma separated identity hashes"
- class="input-field"
- />
</div>
- <div>
- <div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
- Required Stamp Value
+ </div>
+ </div>
+ </div>
+
+ <div v-else class="space-y-4">
+ <div class="glass-card space-y-4">
+ <div class="flex flex-wrap gap-3 items-center">
+ <div class="flex-1">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ Discovery
+ </div>
+ <div class="text-xl font-semibold text-gray-900 dark:text-white">
+ Interface Discovery
+ </div>
+ <div class="text-sm text-gray-600 dark:text-gray-300">
+ Publish your interfaces for others to find, or listen for announced entrypoints
+ and auto-connect to them.
</div>
- <input
- v-model.number="discoveryConfig.required_discovery_value"
- type="number"
- min="0"
- class="input-field"
- />
</div>
- <div>
- <div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
- Auto-connect Slots
+ <RouterLink :to="{ name: 'interfaces.add' }" class="secondary-chip text-sm">
+ <MaterialDesignIcon icon-name="lan" class="w-4 h-4" />
+ Configure Per-Interface
+ </RouterLink>
+ </div>
+ <div class="grid gap-4 md:grid-cols-2">
+ <div class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
+ <div class="font-semibold text-gray-900 dark:text-white">Publish (Server)</div>
+ <div>
+ Enable discovery while adding or editing an interface to broadcast reachable
+ details. Reticulum will sign and stamp announces automatically.
+ </div>
+ <div class="text-xs text-gray-500 dark:text-gray-400">
+ Requires LXMF in the Python environment. Transport is optional for publishing,
+ but usually recommended so peers can connect back.
</div>
- <input
- v-model.number="discoveryConfig.autoconnect_discovered_interfaces"
- type="number"
- min="0"
- class="input-field"
- />
- <div class="text-xs text-gray-500 dark:text-gray-400">0 disables auto-connect.</div>
</div>
- <div>
- <div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
- Network Identity Path
+ <div class="space-y-3">
+ <div class="flex items-center">
+ <div class="flex flex-col mr-auto">
+ <div class="text-sm font-semibold text-gray-900 dark:text-white">
+ Discover Interfaces (Peer)
+ </div>
+ <div class="text-xs text-gray-500 dark:text-gray-400">
+ Listen for discovery announces and optionally auto-connect to available
+ interfaces.
+ </div>
+ </div>
+ <Toggle v-model="discoveryConfig.discover_interfaces" class="my-auto mx-2" />
+ </div>
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
+ <div>
+ <div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
+ Allowed Sources
+ </div>
+ <input
+ v-model="discoveryConfig.interface_discovery_sources"
+ type="text"
+ placeholder="Comma separated identity hashes"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
+ Required Stamp Value
+ </div>
+ <input
+ v-model.number="discoveryConfig.required_discovery_value"
+ type="number"
+ min="0"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
+ Auto-connect Slots
+ </div>
+ <input
+ v-model.number="discoveryConfig.autoconnect_discovered_interfaces"
+ type="number"
+ min="0"
+ class="input-field"
+ />
+ <div class="text-xs text-gray-500 dark:text-gray-400">
+ 0 disables auto-connect.
+ </div>
+ </div>
+ <div>
+ <div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
+ Network Identity Path
+ </div>
+ <input
+ v-model="discoveryConfig.network_identity"
+ type="text"
+ placeholder="~/.reticulum/storage/identities/..."
+ class="input-field"
+ />
+ </div>
+ </div>
+ <div class="flex justify-end">
+ <button
+ type="button"
+ class="primary-chip text-xs"
+ :disabled="savingDiscovery"
+ @click="saveDiscoveryConfig"
+ >
+ <MaterialDesignIcon
+ :icon-name="savingDiscovery ? 'progress-clock' : 'content-save'"
+ class="w-4 h-4"
+ :class="{ 'animate-spin-reverse': savingDiscovery }"
+ />
+ <span class="ml-1">Save Discovery Settings</span>
+ </button>
</div>
- <input
- v-model="discoveryConfig.network_identity"
- type="text"
- placeholder="~/.reticulum/storage/identities/..."
- class="input-field"
- />
</div>
</div>
- <div class="flex justify-end">
- <button
- type="button"
- class="primary-chip text-xs"
- :disabled="savingDiscovery"
- @click="saveDiscoveryConfig"
- >
- <MaterialDesignIcon
- :icon-name="savingDiscovery ? 'progress-clock' : 'content-save'"
- class="w-4 h-4"
- :class="{ 'animate-spin-reverse': savingDiscovery }"
- />
- <span class="ml-1">Save Discovery Settings</span>
- </button>
- </div>
</div>
</div>
</div>
-
- <div v-if="filteredInterfaces.length !== 0" class="grid gap-4 xl:grid-cols-2">
- <Interface
- v-for="iface of filteredInterfaces"
- :key="iface._name"
- :iface="iface"
- :is-reticulum-running="isReticulumRunning"
- @enable="enableInterface(iface._name)"
- @disable="disableInterface(iface._name)"
- @edit="editInterface(iface._name)"
- @export="exportInterface(iface._name)"
- @delete="deleteInterface(iface._name)"
- />
- </div>
</div>
</div>
</div>
@@ -286,6 +520,10 @@ export default {
network_identity: "",
},
savingDiscovery: false,
+ discoveredInterfaces: [],
+ discoveredActive: [],
+ discoveryInterval: null,
+ activeTab: "overview",
};
},
computed: {
@@ -357,19 +595,57 @@ export default {
this.interfacesWithStats.forEach((iface) => types.add(iface.type));
return Array.from(types).sort();
},
+ sortedDiscoveredInterfaces() {
+ return [...this.discoveredInterfaces].sort((a, b) => (b.last_heard || 0) - (a.last_heard || 0));
+ },
+ interfacesWithLocation() {
+ return this.discoveredInterfaces.filter((iface) => iface.latitude != null && iface.longitude != null);
+ },
+ activeInterfaceStats() {
+ return Object.values(this.interfaceStats || {});
+ },
+ tabChipClass() {
+ return (isActive) => (isActive ? "primary-chip text-xs" : "secondary-chip text-xs");
+ },
+ discoveredActiveSet() {
+ const set = new Set();
+ this.discoveredActive.forEach((a) => {
+ const host = a.target_host || a.remote || a.listen_ip;
+ const port = a.target_port || a.listen_port;
+ if (host && port) {
+ set.add(`${host}:${port}`);
+ }
+ });
+ return set;
+ },
+ discoveredActiveTransportIds() {
+ const set = new Set();
+ this.discoveredActive.forEach((a) => {
+ if (a.transport_id) {
+ set.add(a.transport_id);
+ }
+ });
+ return set;
+ },
},
beforeUnmount() {
clearInterval(this.reloadInterval);
+ clearInterval(this.discoveryInterval);
},
mounted() {
this.loadInterfaces();
this.updateInterfaceStats();
this.loadDiscoveryConfig();
+ this.loadDiscoveredInterfaces();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.updateInterfaceStats();
}, 1000);
+
+ this.discoveryInterval = setInterval(() => {
+ this.loadDiscoveredInterfaces();
+ }, 5000);
},
methods: {
relaunch() {
@@ -506,6 +782,77 @@ export default {
this.trackInterfaceChange();
}
},
+ async loadDiscoveredInterfaces() {
+ try {
+ const response = await window.axios.get(`/api/v1/reticulum/discovered-interfaces`);
+ this.discoveredInterfaces = response.data?.interfaces ?? [];
+ this.discoveredActive = response.data?.active ?? [];
+ } catch (e) {
+ console.log(e);
+ }
+ },
+ formatLastHeard(ts) {
+ const seconds = Math.max(0, Math.floor(Date.now() / 1000 - ts));
+ if (seconds < 60) return `${seconds}s ago`;
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
+ return `${Math.floor(seconds / 86400)}d ago`;
+ },
+ isDiscoveredConnected(iface) {
+ const reach = iface.reachable_on;
+ const port = iface.port;
+ if (iface.transport_id && this.discoveredActiveTransportIds.has(iface.transport_id)) {
+ return true;
+ }
+ if (reach && port && this.discoveredActiveSet && this.discoveredActiveSet.has(`${reach}:${port}`)) {
+ return true;
+ }
+ return this.activeInterfaceStats.some((s) => {
+ const hostMatch =
+ (s.target_host && reach && s.target_host === reach) || (s.remote && reach && s.remote === reach);
+ const portMatch =
+ (s.target_port && port && Number(s.target_port) === Number(port)) ||
+ (s.listen_port && port && Number(s.listen_port) === Number(port));
+ return hostMatch && portMatch && (s.connected || s.online);
+ });
+ },
+ goToMap(iface) {
+ if (iface.latitude == null || iface.longitude == null) return;
+ this.$router.push({
+ name: "map",
+ query: {
+ lat: iface.latitude,
+ lon: iface.longitude,
+ label: iface.name,
+ },
+ });
+ },
+ mapAllDiscovered() {
+ this.$router.push({
+ name: "map",
+ query: { view: "discovered" },
+ });
+ },
+ discoveredBytes(iface) {
+ const reach = iface.reachable_on;
+ const port = iface.port;
+ const stats = this.activeInterfaceStats || [];
+ const match = stats.find((s) => {
+ const host = s.target_host || s.remote || s.interface_name;
+ const p = s.target_port || s.listen_port;
+ const hostMatch = host && reach && host === reach;
+ const portMatch = p && port && Number(p) === Number(port);
+ return hostMatch && portMatch;
+ });
+ if (!match) return null;
+ return {
+ tx: this.formatBytes(match.txb || 0),
+ rx: this.formatBytes(match.rxb || 0),
+ };
+ },
+ formatBytes(bytes) {
+ return Utils.formatBytes(bytes || 0);
+ },
parseBool(value) {
if (typeof value === "string") {
return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
@@ -565,6 +912,39 @@ export default {
this.savingDiscovery = false;
}
},
+ getDiscoveryIcon(iface) {
+ switch (iface.type) {
+ case "AutoInterface":
+ return "home-automation";
+ case "RNodeInterface":
+ return iface.port && iface.port.toString().startsWith("tcp://") ? "lan-connect" : "radio-tower";
+ case "RNodeMultiInterface":
+ return "access-point-network";
+ case "TCPClientInterface":
+ case "BackboneInterface":
+ return "lan-connect";
+ case "TCPServerInterface":
+ return "lan";
+ case "UDPInterface":
+ return "wan";
+ case "SerialInterface":
+ return "usb-port";
+ case "KISSInterface":
+ case "AX25KISSInterface":
+ return "antenna";
+ case "I2PInterface":
+ return "eye";
+ case "PipeInterface":
+ return "pipe";
+ default:
+ return "server-network";
+ }
+ },
+ copyToClipboard(text, label) {
+ if (!text) return;
+ navigator.clipboard.writeText(text);
+ ToastUtils.success(`${label} copied to clipboard`);
+ },
setStatusFilter(value) {
this.statusFilter = value;
},
diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index 85c72196..b8aeb4a4 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -28,6 +28,13 @@
<!-- offline/online toggle -->
<div class="flex items-center bg-gray-100 dark:bg-zinc-800 rounded-lg p-1">
+ <button
+ class="p-2 rounded-lg text-gray-500 hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors mr-1"
+ title="Map Discovered Interfaces"
+ @click="mapDiscoveredNodes"
+ >
+ <MaterialDesignIcon icon-name="map-marker-radius" class="size-5" />
+ </button>
<button
:class="
!offlineEnabled
@@ -1059,6 +1066,7 @@ import { fromCircle } from "ol/geom/Polygon";
import { unByKey } from "ol/Observable";
import Overlay from "ol/Overlay";
import GeoJSON from "ol/format/GeoJSON";
+import { extend as extendExtent, createEmpty as createEmptyExtent, isEmpty as isExtentEmpty } from "ol/extent";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import TileCache from "../../js/TileCache";
@@ -1090,6 +1098,8 @@ export default {
markerSource: null,
markerLayer: null,
selectedMarker: null,
+ queryMarker: null,
+ discoveredMarkers: [],
// caching
cachingEnabled: true,
@@ -1256,6 +1266,11 @@ export default {
await this.fetchPeers();
await this.fetchTelemetryMarkers();
+ // Handle view modes
+ if (this.$route.query.view === "discovered") {
+ await this.mapDiscoveredNodes();
+ }
+
// Listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
@@ -1264,10 +1279,29 @@ export default {
const lat = parseFloat(this.$route.query.lat);
const lon = parseFloat(this.$route.query.lon);
const zoom = parseInt(this.$route.query.zoom || 15);
+ const label = this.$route.query.label || "Target";
if (!isNaN(lat) && !isNaN(lon)) {
this.map.getView().setCenter(fromLonLat([lon, lat]));
this.map.getView().setZoom(zoom);
+
+ // add a temporary marker for the query target
+ const feature = new Feature({
+ geometry: new Point(fromLonLat([lon, lat])),
+ });
+ feature.setStyle(
+ this.createMarkerStyle({
+ iconColor: "#2563eb",
+ bgColor: "#bfdbfe",
+ label,
+ isStale: false,
+ iconPath: null,
+ })
+ );
+ this.queryMarker = feature;
+ if (this.markerSource) {
+ this.markerSource.addFeature(feature);
+ }
}
}
@@ -2962,18 +2996,83 @@ export default {
if (!this.markerSource) return;
this.markerSource.clear();
+ const featuresByCoord = {};
+
+ // Helper to collect features
+ const addFeatureToGroup = (coord, feature) => {
+ // Round coordinates to handle floating point jitter
+ const key = coord.map((c) => c.toFixed(6)).join(",");
+ if (!featuresByCoord[key]) featuresByCoord[key] = [];
+ featuresByCoord[key].push(feature);
+ };
+
+ // Process telemetry
for (const t of this.telemetryList) {
const loc = t.telemetry?.location;
if (!loc || loc.latitude === undefined || loc.longitude === undefined) continue;
+ const coord = fromLonLat([loc.longitude, loc.latitude]);
const feature = new Feature({
- geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
+ geometry: new Point(coord),
telemetry: t,
peer: this.peers[t.destination_hash],
});
+ addFeatureToGroup(coord, feature);
+ }
- this.markerSource.addFeature(feature);
+ // Process query marker
+ if (this.queryMarker) {
+ const coord = this.queryMarker.getGeometry().getCoordinates();
+ addFeatureToGroup(coord, this.queryMarker);
+ }
+
+ // Process discovered markers
+ if (this.discoveredMarkers && this.discoveredMarkers.length > 0) {
+ for (const feature of this.discoveredMarkers) {
+ const coord = feature.getGeometry().getCoordinates();
+ addFeatureToGroup(coord, feature);
+ }
}
+
+ // Now handle groups (Marker Explosion)
+ const view = this.map.getView();
+ const resolution = view.getResolution();
+ const offsetDist = resolution * 40; // 40 pixels offset
+
+ Object.entries(featuresByCoord).forEach(([coordStr, features]) => {
+ const trueCoord = coordStr.split(",").map(Number);
+
+ if (features.length === 1) {
+ this.markerSource.addFeature(features[0]);
+ } else {
+ features.forEach((feature, index) => {
+ const angle = (index / features.length) * 2 * Math.PI;
+ const offsetCoord = [
+ trueCoord[0] + Math.cos(angle) * offsetDist,
+ trueCoord[1] + Math.sin(angle) * offsetDist,
+ ];
+
+ // Move the marker to offset position
+ feature.setGeometry(new Point(offsetCoord));
+ this.markerSource.addFeature(feature);
+
+ // Draw dashed line to true position
+ const lineFeature = new Feature({
+ geometry: new LineString([offsetCoord, trueCoord]),
+ });
+ lineFeature.setStyle(
+ new Style({
+ stroke: new Stroke({
+ color: "rgba(59, 130, 246, 0.6)",
+ width: 1.5,
+ lineDash: [4, 4],
+ }),
+ })
+ );
+ this.markerSource.addFeature(lineFeature);
+ });
+ }
+ });
},
createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath }) {
const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}`;
@@ -3042,6 +3141,59 @@ export default {
params: { destinationHash: hash },
});
},
+ async mapDiscoveredNodes() {
+ try {
+ const response = await window.axios.get("/api/v1/reticulum/discovered-interfaces");
+ const discovered = response.data?.interfaces ?? [];
+ const nodesWithLoc = discovered.filter((n) => n.latitude != null && n.longitude != null);
+
+ if (nodesWithLoc.length === 0) {
+ ToastUtils.info("No discovered nodes with location found");
+ return;
+ }
+
+ const extent = createEmptyExtent();
+ this.discoveredMarkers = [];
+
+ for (const node of nodesWithLoc) {
+ const coord = fromLonLat([node.longitude, node.latitude]);
+ extendExtent(extent, coord);
+
+ // Add markers
+ const feature = new Feature({
+ geometry: new Point(coord),
+ discovered: node,
+ });
+ feature.setStyle(
+ this.createMarkerStyle({
+ iconColor: "#10b981", // emerald-500
+ bgColor: "#d1fae5", // emerald-100
+ label: node.name,
+ isStale: false,
+ iconPath: null,
+ })
+ );
+ this.discoveredMarkers.push(feature);
+ }
+
+ // refresh all markers
+ this.updateMarkers();
+
+ // Fit view to all discovered nodes if extent is valid
+ if (!isExtentEmpty(extent)) {
+ this.map.getView().fit(extent, {
+ padding: [100, 100, 100, 100],
+ maxZoom: 12,
+ duration: 1000,
+ });
+ }
+
+ ToastUtils.success(`Mapped ${nodesWithLoc.length} discovered nodes`);
+ } catch (e) {
+ console.error("Failed to map discovered nodes", e);
+ ToastUtils.error("Failed to fetch discovered nodes for mapping");
+ }
+ },
},
};
</script>
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 1aa1de28..8c48a6e9 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -5,6 +5,8 @@
:class="{ 'hidden sm:flex': destinationHash }"
:conversations="conversations"
:peers="peers"
+ :folders="folders"
+ :selected-folder-id="selectedFolderId"
:selected-destination-hash="selectedPeer?.destination_hash"
:conversation-search-term="conversationSearchTerm"
:filter-unread-only="filterUnreadOnly"
@@ -25,6 +27,15 @@
@ingest-paper-message="openIngestPaperMessageModal"
@load-more="loadMoreConversations"
@load-more-announces="loadMoreAnnounces"
+ @folder-click="onFolderClick"
+ @create-folder="onCreateFolder"
+ @rename-folder="onRenameFolder"
+ @delete-folder="onDeleteFolder"
+ @move-to-folder="onMoveToFolder"
+ @bulk-mark-as-read="onBulkMarkAsRead"
+ @bulk-delete="onBulkDelete"
+ @export-folders="onExportFolders"
+ @import-folders="onImportFolders"
/>
<div
@@ -141,6 +152,8 @@ export default {
selectedPeer: null,
conversations: [],
+ folders: [],
+ selectedFolderId: null,
pageSize: 50,
hasMoreConversations: true,
isLoadingMore: false,
@@ -201,11 +214,13 @@ export default {
this.getConfig();
this.getConversations();
+ this.getFolders();
this.getLxmfDeliveryAnnounces();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.getConversations();
+ this.getFolders();
}, 5000);
// compose message if a destination hash was provided on page load
@@ -395,6 +410,129 @@ export default {
this.isLoadingMore = false;
}
},
+ async getFolders() {
+ try {
+ const response = await window.axios.get("/api/v1/lxmf/folders");
+ this.folders = response.data;
+ } catch (e) {
+ console.error("Failed to load folders", e);
+ }
+ },
+ async onCreateFolder(name) {
+ try {
+ await window.axios.post("/api/v1/lxmf/folders", { name });
+ await this.getFolders();
+ ToastUtils.success("Folder created");
+ } catch {
+ ToastUtils.error("Failed to create folder");
+ }
+ },
+ async onRenameFolder({ id, name }) {
+ try {
+ await window.axios.patch(`/api/v1/lxmf/folders/${id}`, { name });
+ await this.getFolders();
+ ToastUtils.success("Folder renamed");
+ } catch {
+ ToastUtils.error("Failed to rename folder");
+ }
+ },
+ async onDeleteFolder(id) {
+ try {
+ await window.axios.delete(`/api/v1/lxmf/folders/${id}`);
+ if (this.selectedFolderId === id) {
+ this.selectedFolderId = null;
+ }
+ await this.getFolders();
+ await this.getConversations();
+ ToastUtils.success("Folder deleted");
+ } catch {
+ ToastUtils.error("Failed to delete folder");
+ }
+ },
+ async onMoveToFolder({ peer_hashes, folder_id }) {
+ try {
+ // Treat 0 as null (Uncategorized) for the backend
+ const targetFolderId = folder_id === 0 ? null : folder_id;
+ await window.axios.post("/api/v1/lxmf/conversations/move-to-folder", {
+ peer_hashes,
+ folder_id: targetFolderId,
+ });
+ await this.getConversations();
+ ToastUtils.success("Moved to folder");
+ } catch {
+ ToastUtils.error("Failed to move to folder");
+ }
+ },
+ async onBulkMarkAsRead(destination_hashes) {
+ try {
+ await window.axios.post("/api/v1/lxmf/conversations/bulk-mark-as-read", {
+ destination_hashes,
+ });
+ await this.getConversations();
+ ToastUtils.success("Marked as read");
+ } catch {
+ ToastUtils.error("Failed to mark as read");
+ }
+ },
+ async onBulkDelete(destination_hashes) {
+ try {
+ const confirmed = await DialogUtils.confirm(
+ "Are you sure you want to delete these conversations? All messages will be lost.",
+ "Delete Conversations"
+ );
+ if (!confirmed) return;
+
+ await window.axios.post("/api/v1/lxmf/conversations/bulk-delete", {
+ destination_hashes,
+ });
+ await this.getConversations();
+ ToastUtils.success("Conversations deleted");
+ } catch {
+ ToastUtils.error("Failed to delete conversations");
+ }
+ },
+ async onExportFolders() {
+ try {
+ const response = await window.axios.get("/api/v1/lxmf/folders/export");
+ const data = JSON.stringify(response.data, null, 2);
+ const blob = new Blob([data], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `meshchatx-folders-${new Date().toISOString().slice(0, 10)}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch {
+ ToastUtils.error("Failed to export folders");
+ }
+ },
+ async onImportFolders() {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = ".json";
+ input.onchange = async (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = async (re) => {
+ try {
+ const data = JSON.parse(re.target.result);
+ await window.axios.post("/api/v1/lxmf/folders/import", data);
+ await this.getFolders();
+ await this.getConversations();
+ ToastUtils.success("Folders imported");
+ } catch {
+ ToastUtils.error("Failed to import folders");
+ }
+ };
+ reader.readAsText(file);
+ };
+ input.click();
+ },
+ onFolderClick(folderId) {
+ this.selectedFolderId = folderId;
+ this.requestConversationsRefresh();
+ },
async loadMoreConversations() {
if (this.isLoadingMore || !this.hasMoreConversations) return;
this.isLoadingMore = true;
@@ -414,6 +552,9 @@ export default {
if (this.filterHasAttachmentsOnly) {
params.filter_has_attachments = true;
}
+ if (this.selectedFolderId !== null) {
+ params.folder_id = this.selectedFolderId;
+ }
return params;
},
updatePeerFromAnnounce: function (announce) {
diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index 9568e416..6b87583f 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -33,6 +33,145 @@
v-if="tab === 'conversations'"
class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0"
>
+ <!-- Folders Section -->
+ <div class="border-b border-gray-200 dark:border-zinc-700 bg-gray-50/50 dark:bg-zinc-900/50">
+ <div
+ class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
+ @click="foldersExpanded = !foldersExpanded"
+ >
+ <div class="flex items-center gap-2">
+ <MaterialDesignIcon
+ :icon-name="foldersExpanded ? 'chevron-down' : 'chevron-right'"
+ class="size-4 text-gray-400"
+ />
+ <span class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-zinc-500">
+ Folders
+ </span>
+ </div>
+ <div class="flex gap-1" @click.stop>
+ <button
+ type="button"
+ class="p-1 text-gray-400 hover:text-blue-500 hover:bg-gray-200/50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
+ title="Create Folder"
+ @click="createFolder"
+ >
+ <MaterialDesignIcon icon-name="folder-plus-outline" class="size-4" />
+ </button>
+ <div class="relative">
+ <button
+ type="button"
+ class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300 hover:bg-gray-200/50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
+ @click="folderMenu.show = !folderMenu.show"
+ >
+ <MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
+ </button>
+ <div
+ v-if="folderMenu.show"
+ v-click-outside="{ handler: () => (folderMenu.show = false), capture: true }"
+ class="absolute right-0 top-full mt-1 z-[60] min-w-[160px] bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-zinc-700 py-1 overflow-hidden animate-in fade-in zoom-in duration-100"
+ >
+ <button
+ type="button"
+ class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
+ @click="
+ $emit('export-folders');
+ folderMenu.show = false;
+ "
+ >
+ <MaterialDesignIcon icon-name="export" class="size-4" />
+ <span>Export Folders</span>
+ </button>
+ <button
+ type="button"
+ class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
+ @click="
+ $emit('import-folders');
+ folderMenu.show = false;
+ "
+ >
+ <MaterialDesignIcon icon-name="import" class="size-4" />
+ <span>Import Folders</span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div v-if="foldersExpanded" class="flex flex-col max-h-48 overflow-y-auto pb-1">
+ <div
+ class="px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
+ :class="[
+ selectedFolderId === null
+ ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
+ : 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
+ dragOverFolderId === 'all'
+ ? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
+ : '',
+ ]"
+ @click="$emit('folder-click', null)"
+ @dragover="onDragOver($event, 'all')"
+ @dragleave="onDragLeave"
+ @drop="onDropOnFolder($event, null)"
+ >
+ <MaterialDesignIcon icon-name="inbox-outline" class="size-4" />
+ <span class="truncate flex-1">All Messages</span>
+ </div>
+ <div
+ class="px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
+ :class="[
+ selectedFolderId === 0
+ ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
+ : 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
+ dragOverFolderId === 0
+ ? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
+ : '',
+ ]"
+ @click="$emit('folder-click', 0)"
+ @dragover="onDragOver($event, 0)"
+ @dragleave="onDragLeave"
+ @drop="onDropOnFolder($event, 0)"
+ >
+ <MaterialDesignIcon icon-name="folder-outline" class="size-4" />
+ <span class="truncate flex-1">Uncategorized</span>
+ </div>
+ <div
+ v-for="folder in folders"
+ :key="folder.id"
+ class="group px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
+ :class="[
+ selectedFolderId === folder.id
+ ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
+ : 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
+ dragOverFolderId === folder.id
+ ? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
+ : '',
+ ]"
+ @click="$emit('folder-click', folder.id)"
+ @dragover="onDragOver($event, folder.id)"
+ @dragleave="onDragLeave"
+ @drop="onDropOnFolder($event, folder.id)"
+ >
+ <MaterialDesignIcon icon-name="folder" class="size-4" />
+ <span class="truncate flex-1">{{ folder.name }}</span>
+ <div class="hidden group-hover:flex items-center gap-0.5">
+ <button
+ type="button"
+ class="p-1 hover:text-blue-500 hover:bg-white dark:hover:bg-zinc-700 rounded-lg transition-colors"
+ @click.stop="renameFolder(folder)"
+ >
+ <MaterialDesignIcon icon-name="pencil-outline" class="size-3.5" />
+ </button>
+ <button
+ type="button"
+ class="p-1 hover:text-red-500 hover:bg-white dark:hover:bg-zinc-700 rounded-lg transition-colors"
+ @click.stop="deleteFolder(folder)"
+ >
+ <MaterialDesignIcon icon-name="trash-can-outline" class="size-3.5" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
<!-- search + filters -->
<div
v-if="conversations.length > 0 || isFilterActive"
@@ -46,6 +185,15 @@
class="input-field flex-1"
@input="onConversationSearchInput"
/>
+ <button
+ type="button"
+ class="p-2 bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 rounded-lg hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
+ title="Selection Mode"
+ :class="{ 'text-blue-500 bg-blue-50 dark:bg-blue-900/20': selectionMode }"
+ @click="toggleSelectionMode"
+ >
+ <MaterialDesignIcon icon-name="checkbox-multiple-marked-outline" class="size-5" />
+ </button>
<button
type="button"
class="p-2 bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 rounded-lg hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
@@ -55,6 +203,69 @@
<MaterialDesignIcon icon-name="qrcode-scan" class="size-5" />
</button>
</div>
+ <div
+ v-if="selectionMode"
+ class="flex items-center justify-between px-2 py-1 bg-blue-50 dark:bg-blue-900/10 rounded-lg"
+ >
+ <div class="flex items-center gap-2">
+ <input
+ type="checkbox"
+ :checked="allSelected"
+ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
+ @change="toggleSelectAll"
+ />
+ <span class="text-xs font-semibold text-blue-700 dark:text-blue-400">
+ {{ selectedHashes.size }} selected
+ </span>
+ </div>
+ <div class="flex gap-2">
+ <button
+ type="button"
+ class="text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline"
+ @click="bulkMarkAsRead"
+ >
+ Mark as read
+ </button>
+ <button
+ type="button"
+ class="text-xs font-bold text-red-600 dark:text-red-400 hover:underline"
+ @click="bulkDelete"
+ >
+ Delete
+ </button>
+ <div class="relative">
+ <button
+ type="button"
+ class="text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline"
+ @click="moveMenu.show = !moveMenu.show"
+ >
+ Move to
+ </button>
+ <div
+ v-if="moveMenu.show"
+ v-click-outside="{ handler: () => (moveMenu.show = false), capture: true }"
+ class="absolute right-0 top-full mt-1 z-[60] min-w-[160px] bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-zinc-700 py-1 overflow-hidden animate-in fade-in zoom-in duration-100"
+ >
+ <button
+ type="button"
+ class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
+ @click="moveSelectedToFolder(null)"
+ >
+ Uncategorized
+ </button>
+ <button
+ v-for="folder in folders"
+ :key="folder.id"
+ type="button"
+ class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
+ @click="moveSelectedToFolder(folder.id)"
+ >
+ {{ folder.name }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
<div class="flex flex-wrap gap-1">
<button type="button" :class="filterChipClasses(filterUnreadOnly)" @click="toggleFilter('unread')">
{{ $t("messages.unread") }}
@@ -96,15 +307,38 @@
conversation.failed_messages_count,
selectedDestinationHash === conversation.destination_hash,
GlobalState.config.banished_effect_enabled && isBlocked(conversation.destination_hash),
+ selectionMode,
+ selectedHashes.has(conversation.destination_hash),
]"
- class="flex cursor-pointer p-2 border-l-2 relative"
+ class="flex cursor-pointer p-2 border-l-2 relative group conversation-item"
:class="[
conversation.destination_hash === selectedDestinationHash
? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400'
: 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600',
+ selectedHashes.has(conversation.destination_hash)
+ ? 'bg-blue-50/50 dark:bg-blue-900/10'
+ : '',
]"
- @click="onConversationClick(conversation)"
+ draggable="true"
+ @click="
+ selectionMode
+ ? toggleSelectConversation(conversation.destination_hash)
+ : onConversationClick(conversation)
+ "
+ @contextmenu="onRightClick($event, conversation.destination_hash)"
+ @dragstart="onDragStart($event, conversation.destination_hash)"
>
+ <!-- Selection Checkbox -->
+ <div v-if="selectionMode" class="my-auto mr-3 px-1">
+ <input
+ type="checkbox"
+ :checked="selectedHashes.has(conversation.destination_hash)"
+ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
+ @click.stop
+ @change="toggleSelectConversation(conversation.destination_hash)"
+ />
+ </div>
+
<!-- banished overlay -->
<div
v-if="
@@ -177,6 +411,58 @@
</div>
</div>
+ <!-- Context Menu -->
+ <div
+ v-if="contextMenu.show"
+ v-click-outside="{ handler: () => (contextMenu.show = false), capture: true }"
+ class="fixed z-[100] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
+ :style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
+ >
+ <button
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
+ @click="bulkMarkAsRead"
+ >
+ <MaterialDesignIcon icon-name="email-open-outline" class="size-4 text-gray-400" />
+ <span class="font-medium">Mark as Read</span>
+ </button>
+ <div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
+ <div
+ class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
+ >
+ Move to Folder
+ </div>
+ <button
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
+ @click="moveSelectedToFolder(null)"
+ >
+ <MaterialDesignIcon icon-name="inbox-arrow-down" class="size-4 opacity-70" />
+ <span>Uncategorized</span>
+ </button>
+ <div class="max-h-[200px] overflow-y-auto custom-scrollbar">
+ <button
+ v-for="folder in folders"
+ :key="folder.id"
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
+ @click="moveSelectedToFolder(folder.id)"
+ >
+ <MaterialDesignIcon icon-name="folder" class="size-4 opacity-70" />
+ <span class="truncate">{{ folder.name }}</span>
+ </button>
+ </div>
+ <div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
+ <button
+ type="button"
+ class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
+ @click="bulkDelete"
+ >
+ <MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
+ <span class="font-bold">Delete</span>
+ </button>
+ </div>
+
<!-- loading more spinner -->
<div v-if="isLoadingMore" class="p-4 text-center">
<MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
@@ -340,6 +626,7 @@
<script>
import Utils from "../../js/Utils";
+import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import GlobalState from "../../js/GlobalState";
@@ -356,6 +643,14 @@ export default {
type: Array,
required: true,
},
+ folders: {
+ type: Array,
+ default: () => [],
+ },
+ selectedFolderId: {
+ type: [Number, String],
+ default: null,
+ },
selectedDestinationHash: {
type: String,
required: true,
@@ -414,11 +709,45 @@ export default {
"ingest-paper-message",
"load-more",
"load-more-announces",
+ "folder-click",
+ "create-folder",
+ "rename-folder",
+ "delete-folder",
+ "move-to-folder",
+ "bulk-mark-as-read",
+ "bulk-delete",
+ "export-folders",
+ "import-folders",
],
data() {
+ let foldersExpanded = true;
+ try {
+ if (typeof localStorage !== "undefined") {
+ foldersExpanded = localStorage.getItem("meshchatx_folders_expanded") !== "false";
+ }
+ } catch {
+ // ignore
+ }
return {
GlobalState,
tab: "conversations",
+ foldersExpanded,
+ selectionMode: false,
+ selectedHashes: new Set(),
+ folderMenu: {
+ show: false,
+ },
+ moveMenu: {
+ show: false,
+ },
+ contextMenu: {
+ show: false,
+ x: 0,
+ y: 0,
+ targetHash: null,
+ },
+ draggedHash: null,
+ dragOverFolderId: null,
};
},
computed: {
@@ -461,8 +790,126 @@ export default {
hasUnreadConversations() {
return this.conversations.some((c) => c.is_unread);
},
+ allSelected() {
+ return this.conversations.length > 0 && this.selectedHashes.size === this.conversations.length;
+ },
+ },
+ watch: {
+ foldersExpanded(newVal) {
+ try {
+ if (typeof localStorage !== "undefined") {
+ localStorage.setItem("meshchatx_folders_expanded", newVal);
+ }
+ } catch {
+ // ignore
+ }
+ },
},
methods: {
+ toggleSelectionMode() {
+ this.selectionMode = !this.selectionMode;
+ if (!this.selectionMode) {
+ this.selectedHashes.clear();
+ }
+ },
+ toggleSelectAll() {
+ if (this.allSelected) {
+ this.selectedHashes.clear();
+ } else {
+ this.conversations.forEach((c) => this.selectedHashes.add(c.destination_hash));
+ }
+ },
+ toggleSelectConversation(hash) {
+ if (this.selectedHashes.has(hash)) {
+ this.selectedHashes.delete(hash);
+ } else {
+ this.selectedHashes.add(hash);
+ }
+ },
+ onRightClick(event, hash) {
+ event.preventDefault();
+ if (this.selectionMode && !this.selectedHashes.has(hash)) {
+ this.selectedHashes.add(hash);
+ }
+ this.contextMenu.x = event.clientX;
+ this.contextMenu.y = event.clientY;
+ this.contextMenu.targetHash = hash;
+ this.contextMenu.show = true;
+ },
+ onFolderContextMenu(event) {
+ event.preventDefault();
+ // Show folder management menu
+ },
+ onDragStart(event, hash) {
+ this.draggedHash = hash;
+ event.dataTransfer.setData("text/plain", hash);
+ event.dataTransfer.effectAllowed = "move";
+ },
+ onDragOver(event, folderId) {
+ event.preventDefault();
+ this.dragOverFolderId = folderId;
+ event.dataTransfer.dropEffect = "move";
+ },
+ onDragLeave() {
+ this.dragOverFolderId = null;
+ },
+ onDropOnFolder(event, folderId) {
+ event.preventDefault();
+ this.dragOverFolderId = null;
+ const hash = event.dataTransfer.getData("text/plain");
+ if (hash) {
+ this.$emit("move-to-folder", {
+ peer_hashes: [hash],
+ folder_id: folderId,
+ });
+ }
+ this.draggedHash = null;
+ },
+ async createFolder() {
+ const name = await DialogUtils.prompt("Enter folder name", "New Folder");
+ if (name) {
+ this.$emit("create-folder", name);
+ }
+ },
+ async renameFolder(folder) {
+ const name = await DialogUtils.prompt("Rename folder", folder.name);
+ if (name && name !== folder.name) {
+ this.$emit("rename-folder", { id: folder.id, name });
+ }
+ },
+ async deleteFolder(folder) {
+ const confirmed = await DialogUtils.confirm(
+ `Are you sure you want to delete the folder "${folder.name}"? Conversations will be moved to Uncategorized.`,
+ "Delete Folder"
+ );
+ if (confirmed) {
+ this.$emit("delete-folder", folder.id);
+ }
+ },
+ bulkMarkAsRead() {
+ const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
+ this.$emit("bulk-mark-as-read", hashes);
+ this.contextMenu.show = false;
+ this.moveMenu.show = false;
+ this.folderMenu.show = false;
+ if (this.selectionMode) this.toggleSelectionMode();
+ },
+ bulkDelete() {
+ const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
+ this.$emit("bulk-delete", hashes);
+ this.contextMenu.show = false;
+ this.moveMenu.show = false;
+ this.folderMenu.show = false;
+ if (this.selectionMode) this.toggleSelectionMode();
+ },
+ moveSelectedToFolder(folderId) {
+ const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
+ this.$emit("move-to-folder", { peer_hashes: hashes, folder_id: folderId });
+ this.contextMenu.show = false;
+ this.moveMenu.show = false;
+ this.folderMenu.show = false;
+ if (this.selectionMode) this.toggleSelectionMode();
+ },
isBlocked(destinationHash) {
return this.blockedDestinations.some((b) => b.destination_hash === destinationHash);
},
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index c4557e32..c9f228bf 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -173,6 +173,167 @@
</div>
</section>
+ <!-- Maintenance & Data -->
+ <section class="glass-card break-inside-avoid">
+ <header class="glass-card__header">
+ <div>
+ <div class="glass-card__eyebrow">Maintenance</div>
+ <h2>{{ $t("maintenance.title") }}</h2>
+ <p>{{ $t("maintenance.description") }}</p>
+ </div>
+ </header>
+ <div class="glass-card__body space-y-4">
+ <div class="grid grid-cols-1 gap-3">
+ <button
+ type="button"
+ class="btn-maintenance border-red-200 dark:border-red-900/30 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/10 hover:bg-red-100 dark:hover:bg-red-900/20"
+ @click="clearMessages"
+ >
+ <div class="flex flex-col items-start text-left">
+ <div class="font-bold flex items-center gap-2">
+ <MaterialDesignIcon icon-name="message-remove" class="size-4" />
+ {{ $t("maintenance.clear_messages") }}
+ </div>
+ <div class="text-xs opacity-80">
+ {{ $t("maintenance.clear_messages_desc") }}
+ </div>
+ </div>
+ </button>
+
+ <button
+ type="button"
+ class="btn-maintenance border-orange-200 dark:border-orange-900/30 text-orange-700 dark:text-orange-300 bg-orange-50 dark:bg-orange-900/10 hover:bg-orange-100 dark:hover:bg-orange-900/20"
+ @click="clearAnnounces"
+ >
+ <div class="flex flex-col items-start text-left">
+ <div class="font-bold flex items-center gap-2">
+ <MaterialDesignIcon icon-name="broadcast-off" class="size-4" />
+ {{ $t("maintenance.clear_announces") }}
+ </div>
+ <div class="text-xs opacity-80">
+ {{ $t("maintenance.clear_announces_desc") }}
+ </div>
+ </div>
+ </button>
+
+ <button
+ type="button"
+ class="btn-maintenance border-indigo-200 dark:border-indigo-900/30 text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/10 hover:bg-indigo-100 dark:hover:bg-indigo-900/20"
+ @click="clearNomadnetFavorites"
+ >
+ <div class="flex flex-col items-start text-left">
+ <div class="font-bold flex items-center gap-2">
+ <MaterialDesignIcon icon-name="bookmark-remove" class="size-4" />
+ {{ $t("maintenance.clear_nomadnet_favs") }}
+ </div>
+ <div class="text-xs opacity-80">
+ {{ $t("maintenance.clear_nomadnet_favs_desc") }}
+ </div>
+ </div>
+ </button>
+
+ <button
+ type="button"
+ class="btn-maintenance border-blue-200 dark:border-blue-900/30 text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/10 hover:bg-blue-100 dark:hover:bg-blue-900/20"
+ @click="clearArchives"
+ >
+ <div class="flex flex-col items-start text-left">
+ <div class="font-bold flex items-center gap-2">
+ <MaterialDesignIcon icon-name="delete-sweep" class="size-4" />
+ {{ $t("maintenance.clear_archives") }}
+ </div>
+ <div class="text-xs opacity-80">
+ {{ $t("maintenance.clear_archives_desc") }}
+ </div>
+ </div>
+ </button>
+ </div>
+
+ <div class="space-y-2 pt-2 border-t border-gray-100 dark:border-zinc-800">
+ <div class="text-sm font-medium text-gray-900 dark:text-gray-100">
+ Automatic Backup Limit
+ </div>
+ <input
+ v-model.number="config.backup_max_count"
+ type="number"
+ min="1"
+ max="50"
+ class="input-field"
+ @input="onBackupConfigChange"
+ />
+ <div class="text-xs text-gray-600 dark:text-gray-400">
+ Number of automatic backups to keep.
+ </div>
+ </div>
+
+ <div class="grid grid-cols-2 gap-3 mt-4">
+ <button
+ type="button"
+ class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-blue-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-blue-500 transition group"
+ @click="exportMessages"
+ >
+ <MaterialDesignIcon
+ icon-name="export"
+ class="size-6 text-blue-500 group-hover:scale-110 transition"
+ />
+ <div class="text-sm font-bold">{{ $t("maintenance.export_messages") }}</div>
+ </button>
+
+ <button
+ type="button"
+ class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-emerald-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-emerald-500 transition group"
+ @click="triggerImport"
+ >
+ <MaterialDesignIcon
+ icon-name="import"
+ class="size-6 text-emerald-500 group-hover:scale-110 transition"
+ />
+ <div class="text-sm font-bold">{{ $t("maintenance.import_messages") }}</div>
+ </button>
+ <input
+ ref="importFile"
+ type="file"
+ accept=".json"
+ class="hidden"
+ @change="importMessages"
+ />
+ </div>
+
+ <div class="grid grid-cols-2 gap-3 mt-2 pt-4 border-t border-gray-100 dark:border-zinc-800">
+ <button
+ type="button"
+ class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-purple-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-purple-500 transition group"
+ @click="exportFolders"
+ >
+ <MaterialDesignIcon
+ icon-name="folder-export-outline"
+ class="size-6 text-purple-500 group-hover:scale-110 transition"
+ />
+ <div class="text-sm font-bold">Export Folders</div>
+ </button>
+
+ <button
+ type="button"
+ class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-indigo-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-indigo-500 transition group"
+ @click="triggerFolderImport"
+ >
+ <MaterialDesignIcon
+ icon-name="folder-import-outline"
+ class="size-6 text-indigo-500 group-hover:scale-110 transition"
+ />
+ <div class="text-sm font-bold">Import Folders</div>
+ </button>
+ <input
+ ref="importFolderFile"
+ type="file"
+ accept=".json"
+ class="hidden"
+ @change="importFolders"
+ />
+ </div>
+ </div>
+ </section>
+
<!-- Desktop / Electron Settings -->
<section v-if="ElectronUtils.isElectron()" class="glass-card break-inside-avoid">
<header class="glass-card__header">
@@ -1313,6 +1474,17 @@ export default {
);
}, 1000);
},
+ async onBackupConfigChange() {
+ if (this.saveTimeouts.backup) clearTimeout(this.saveTimeouts.backup);
+ this.saveTimeouts.backup = setTimeout(async () => {
+ await this.updateConfig(
+ {
+ backup_max_count: this.config.backup_max_count,
+ },
+ "backup_max_count"
+ );
+ }, 1000);
+ },
async flushArchivedPages() {
if (
!(await DialogUtils.confirm(
@@ -1335,19 +1507,15 @@ export default {
async onIsTransportEnabledChange() {
if (this.config.is_transport_enabled) {
try {
- const response = await window.axios.post("/api/v1/reticulum/enable-transport");
- ToastUtils.success(response.data.message);
- } catch (e) {
+ await window.axios.post("/api/v1/reticulum/enable-transport");
+ } catch {
ToastUtils.error("Failed to enable transport mode!");
- console.log(e);
}
} else {
try {
- const response = await window.axios.post("/api/v1/reticulum/disable-transport");
- ToastUtils.success(response.data.message);
- } catch (e) {
+ await window.axios.post("/api/v1/reticulum/disable-transport");
+ } catch {
ToastUtils.error("Failed to disable transport mode!");
- console.log(e);
}
}
},
@@ -1358,13 +1526,130 @@ export default {
this.reloadingRns = true;
const response = await window.axios.post("/api/v1/reticulum/reload");
ToastUtils.success(response.data.message);
- } catch (e) {
- ToastUtils.error(e.response?.data?.error || "Failed to reload Reticulum!");
- console.error(e);
+ } catch {
+ ToastUtils.error("Failed to reload Reticulum!");
} finally {
this.reloadingRns = false;
}
},
+ async clearMessages() {
+ if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
+ try {
+ await window.axios.delete("/api/v1/maintenance/messages");
+ ToastUtils.success(this.$t("maintenance.messages_cleared"));
+ } catch {
+ ToastUtils.error(this.$t("common.error"));
+ }
+ },
+ async clearAnnounces() {
+ if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
+ try {
+ await window.axios.delete("/api/v1/maintenance/announces");
+ ToastUtils.success(this.$t("maintenance.announces_cleared"));
+ } catch {
+ ToastUtils.error(this.$t("common.error"));
+ }
+ },
+ async clearNomadnetFavorites() {
+ if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
+ try {
+ await window.axios.delete("/api/v1/maintenance/favourites", {
+ params: { aspect: "nomadnetwork.node" },
+ });
+ ToastUtils.success(this.$t("maintenance.favourites_cleared"));
+ } catch {
+ ToastUtils.error(this.$t("common.error"));
+ }
+ },
+ async clearArchives() {
+ if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
+ try {
+ await window.axios.delete("/api/v1/maintenance/archives");
+ ToastUtils.success(this.$t("maintenance.archives_cleared"));
+ } catch {
+ ToastUtils.error(this.$t("common.error"));
+ }
+ },
+ async exportMessages() {
+ try {
+ const response = await window.axios.get("/api/v1/maintenance/messages/export");
+ const messages = response.data.messages;
+ const dataStr = JSON.stringify({ messages }, null, 2);
+ const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
+
+ const exportFileDefaultName = `meshchat_messages_${new Date().toISOString().slice(0, 10)}.json`;
+
+ const linkElement = document.createElement("a");
+ linkElement.setAttribute("href", dataUri);
+ linkElement.setAttribute("download", exportFileDefaultName);
+ linkElement.click();
+ } catch {
+ ToastUtils.error(this.$t("common.error"));
+ }
+ },
+ triggerImport() {
+ this.$refs.importFile.click();
+ },
+ async importMessages(event) {
+ const file = event.target.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ try {
+ const data = JSON.parse(e.target.result);
+ if (!data.messages) throw new Error("Invalid file format");
+
+ await window.axios.post("/api/v1/maintenance/messages/import", {
+ messages: data.messages,
+ });
+ ToastUtils.success(this.$t("maintenance.import_success", { count: data.messages.length }));
+ } catch {
+ ToastUtils.error(this.$t("maintenance.import_failed"));
+ }
+ };
+ reader.readAsText(file);
+ // Reset input
+ event.target.value = "";
+ },
+ async exportFolders() {
+ try {
+ const response = await window.axios.get("/api/v1/lxmf/folders/export");
+ const dataStr = JSON.stringify(response.data, null, 2);
+ const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
+ const exportFileDefaultName = `meshchat_folders_${new Date().toISOString().slice(0, 10)}.json`;
+ const linkElement = document.createElement("a");
+ linkElement.setAttribute("href", dataUri);
+ linkElement.setAttribute("download", exportFileDefaultName);
+ linkElement.click();
+ ToastUtils.success("Folders exported");
+ } catch {
+ ToastUtils.error("Failed to export folders");
+ }
+ },
+ triggerFolderImport() {
+ this.$refs.importFolderFile.click();
+ },
+ async importFolders(event) {
+ const file = event.target.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ try {
+ const data = JSON.parse(e.target.result);
+ if (!data.folders || !data.mappings) throw new Error("Invalid file format");
+
+ await window.axios.post("/api/v1/lxmf/folders/import", data);
+ ToastUtils.success("Folders and mappings imported successfully");
+ } catch {
+ ToastUtils.error("Failed to import folders");
+ }
+ };
+ reader.readAsText(file);
+ // Reset input
+ event.target.value = "";
+ },
formatSecondsAgo: function (seconds) {
return Utils.formatSecondsAgo(seconds);
},
@@ -1394,6 +1679,9 @@ export default {
.input-field {
@apply bg-gray-50/90 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-2xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-2.5 text-gray-900 dark:text-gray-100 transition;
}
+.btn-maintenance {
+ @apply w-full px-4 py-3 rounded-2xl border transition flex items-center justify-between;
+}
.setting-toggle {
@apply flex items-start gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 px-3 py-3;
}
diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue
index b54b6d78..d2fd5293 100644
--- a/meshchatx/src/frontend/components/tools/ToolsPage.vue
+++ b/meshchatx/src/frontend/components/tools/ToolsPage.vue
@@ -35,8 +35,16 @@
<RouterLink
v-for="tool in filteredTools"
:key="tool.name"
- :to="tool.route"
- :class="['tool-card', 'glass-card', tool.customClass].filter(Boolean)"
+ :to="tool.comingSoon ? '' : tool.route"
+ :class="
+ [
+ 'tool-card',
+ 'glass-card',
+ tool.customClass,
+ tool.comingSoon ? 'opacity-60 grayscale-[0.5] cursor-default' : '',
+ ].filter(Boolean)
+ "
+ @click="tool.comingSoon ? $event.preventDefault() : null"
>
<div :class="tool.iconBg">
<MaterialDesignIcon v-if="tool.icon" :icon-name="tool.icon" class="w-6 h-6" />
@@ -48,23 +56,33 @@
/>
</div>
<div class="flex-1">
- <div class="tool-card__title">{{ tool.title }}</div>
+ <div class="flex items-center gap-2">
+ <div class="tool-card__title">{{ tool.title }}</div>
+ <span
+ v-if="tool.comingSoon"
+ class="px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 rounded-md border border-gray-200 dark:border-zinc-700"
+ >
+ Soon
+ </span>
+ </div>
<div class="tool-card__description">
{{ tool.description }}
</div>
</div>
- <div v-if="tool.extraAction" class="flex items-center gap-2">
- <a
- :href="tool.extraAction.href"
- :target="tool.extraAction.target"
- class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors text-gray-400 hover:text-blue-500"
- @click.stop
- >
- <MaterialDesignIcon :icon-name="tool.extraAction.icon" class="size-5" />
- </a>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ <div v-if="!tool.comingSoon">
+ <div v-if="tool.extraAction" class="flex items-center gap-2">
+ <a
+ :href="tool.extraAction.href"
+ :target="tool.extraAction.target"
+ class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors text-gray-400 hover:text-blue-500"
+ @click.stop
+ >
+ <MaterialDesignIcon :icon-name="tool.extraAction.icon" class="size-5" />
+ </a>
+ <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ </div>
+ <MaterialDesignIcon v-else icon-name="chevron-right" class="tool-card__chevron" />
</div>
- <MaterialDesignIcon v-else icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
</div>
@@ -193,6 +211,30 @@ export default {
icon: "open-in-new",
},
},
+ {
+ name: "rns-page-node",
+ comingSoon: true,
+ icon: "server-network",
+ iconBg: "tool-card__icon bg-amber-50 text-amber-500 dark:bg-amber-900/30 dark:text-amber-200",
+ titleKey: "tools.rns_page_node.title",
+ descriptionKey: "tools.rns_page_node.description",
+ },
+ {
+ name: "rns-tunnel",
+ comingSoon: true,
+ icon: "tunnel",
+ iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
+ titleKey: "tools.rns_tunnel.title",
+ descriptionKey: "tools.rns_tunnel.description",
+ },
+ {
+ name: "rns-filesync",
+ comingSoon: true,
+ icon: "folder-sync",
+ iconBg: "tool-card__icon bg-emerald-50 text-emerald-500 dark:bg-emerald-900/30 dark:text-emerald-200",
+ titleKey: "tools.rns_filesync.title",
+ descriptionKey: "tools.rns_filesync.description",
+ },
{
name: "debug-logs",
route: { name: "debug-logs" },
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index adee9228..45049502 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -212,7 +212,31 @@
"confirm": "Bestätigen",
"delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden.",
"search": "Werkzeuge suchen...",
- "no_results": "Keine Werkzeuge gefunden"
+ "no_results": "Keine Werkzeuge gefunden",
+ "error": "Fehler"
+ },
+ "maintenance": {
+ "title": "Wartung & Daten",
+ "description": "Verwalten Sie Ihre lokalen Daten, löschen Sie Caches und sichern Sie Konversationen.",
+ "clear_messages": "Alle Nachrichten löschen",
+ "clear_messages_desc": "Alle gesendeten und empfangenen Nachrichten dauerhaft löschen.",
+ "clear_announces": "Alle Ankündigungen löschen",
+ "clear_announces_desc": "Alle entdeckten Peers und Knoten aus dem Cache entfernen.",
+ "clear_nomadnet_favs": "NomadNetwork-Favoriten löschen",
+ "clear_nomadnet_favs_desc": "Alle gespeicherten NomadNetwork-Knoten aus den Favoriten entfernen.",
+ "clear_archives": "Seitenarchive löschen",
+ "clear_archives_desc": "Alle zwischengespeicherten NomadNetwork-Seiten löschen.",
+ "export_messages": "Nachrichten exportieren",
+ "export_messages_desc": "Alle Konversationen als JSON-Datei herunterladen.",
+ "import_messages": "Nachrichten importieren",
+ "import_messages_desc": "Konversationen aus einer JSON-Datei wiederherstellen.",
+ "clear_confirm": "Sind Sie sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
+ "messages_cleared": "Nachrichten erfolgreich gelöscht",
+ "announces_cleared": "Ankündigungen erfolgreich gelöscht",
+ "favourites_cleared": "Favoriten erfolgreich gelöscht",
+ "archives_cleared": "Archive erfolgreich gelöscht",
+ "import_success": "Erfolgreich {count} Nachrichten importiert",
+ "import_failed": "Nachrichten konnten nicht importiert werden"
},
"identities": {
"title": "Identitäten",
@@ -695,6 +719,18 @@
"title": "Papiernachricht",
"description": "Erstellen und lesen Sie LXMF-signierte Papiernachrichten über QR-Codes."
},
+ "rns_page_node": {
+ "title": "RNS Page-Knoten",
+ "description": "Nomadnet-Micron-Seiten hosten und Dateien mühelos teilen."
+ },
+ "rns_tunnel": {
+ "title": "RNS-Tunnel",
+ "description": "Regulären IP-Verkehr über Reticulum-Netzwerkverbindungen tunneln."
+ },
+ "rns_filesync": {
+ "title": "RNS-Dateisynchronisierung",
+ "description": "Dateien effizient mit anderen Mesh-Peers teilen und synchronisieren."
+ },
"bots": {
"title": "LXMFy-Bots",
"description": "Verwalten Sie automatisierte Bots für Echo, Notizen und Erinnerungen mit LXMFy."
@@ -1051,7 +1087,11 @@
"restart_start": "MeshChatX starten",
"skip_setup": "Setup überspringen",
"continue": "Weiter",
- "skip_confirm": "Sind Sie sicher, dass Sie das Setup überspringen möchten? Sie müssen Schnittstellen später manuell hinzufügen."
+ "skip_confirm": "Sind Sie sicher, dass Sie das Setup überspringen möchten? Sie müssen Schnittstellen später manuell hinzufügen.",
+ "discovery_question": "Möchten Sie die Community-Schnittstellenerkennung und Auto-Connect verwenden?",
+ "discovery_desc": "Dies ermöglicht es MeshChatX, automatisch öffentliche Community-Knoten in Ihrer Nähe oder im Internet zu finden und sich mit ihnen zu verbinden.",
+ "yes": "Ja, Erkennung verwenden",
+ "no": "Nein, manuelle Einrichtung"
},
"command_palette": {
"search_placeholder": "Befehle suchen, navigieren oder Peers finden...",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index e663b41d..cef354e8 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -212,7 +212,31 @@
"confirm": "Confirm",
"delete_confirm": "Are you sure you want to delete this? This cannot be undone.",
"search": "Search tools...",
- "no_results": "No tools found"
+ "no_results": "No tools found",
+ "error": "Error"
+ },
+ "maintenance": {
+ "title": "Maintenance & Data",
+ "description": "Manage your local data, clear caches, and backup conversations.",
+ "clear_messages": "Clear All Messages",
+ "clear_messages_desc": "Permanently delete all sent and received messages.",
+ "clear_announces": "Clear All Announces",
+ "clear_announces_desc": "Remove all discovered peers and nodes from your cache.",
+ "clear_nomadnet_favs": "Clear NomadNetwork Favorites",
+ "clear_nomadnet_favs_desc": "Remove all saved NomadNetwork nodes from your favorites.",
+ "clear_archives": "Clear Page Archives",
+ "clear_archives_desc": "Delete all cached NomadNetwork pages.",
+ "export_messages": "Export Messages",
+ "export_messages_desc": "Download all conversations as a JSON file.",
+ "import_messages": "Import Messages",
+ "import_messages_desc": "Restore conversations from a JSON file.",
+ "clear_confirm": "Are you sure? This action cannot be undone.",
+ "messages_cleared": "Messages cleared successfully",
+ "announces_cleared": "Announces cleared successfully",
+ "favourites_cleared": "Favorites cleared successfully",
+ "archives_cleared": "Archives cleared successfully",
+ "import_success": "Successfully imported {count} messages",
+ "import_failed": "Failed to import messages"
},
"identities": {
"title": "Identities",
@@ -695,6 +719,18 @@
"title": "Paper Message",
"description": "Generate and read LXMF signed paper messages via QR codes."
},
+ "rns_page_node": {
+ "title": "RNS Page Node",
+ "description": "Host Nomadnet micron pages and share files with ease."
+ },
+ "rns_tunnel": {
+ "title": "RNS Tunnel",
+ "description": "Tunnel regular IP traffic over Reticulum network links."
+ },
+ "rns_filesync": {
+ "title": "RNS Filesync",
+ "description": "Efficiently share and sync files with other mesh peers."
+ },
"bots": {
"title": "LXMFy Bots",
"description": "Manage automated bots for echo, notes, and reminders using LXMFy."
@@ -1051,7 +1087,11 @@
"restart_start": "Start MeshChatX",
"skip_setup": "Skip Setup",
"continue": "Continue",
- "skip_confirm": "Are you sure you want to skip the setup? You'll need to manually add interfaces later."
+ "skip_confirm": "Are you sure you want to skip the setup? You'll need to manually add interfaces later.",
+ "discovery_question": "Do you want to use community interface discovering and auto-connect?",
+ "discovery_desc": "This allows MeshChatX to automatically find and connect to public community nodes near you or on the internet.",
+ "yes": "Yes, use discovery",
+ "no": "No, manual setup"
},
"command_palette": {
"search_placeholder": "Search commands, navigate, or find peers...",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index e63e46ba..fca28ef1 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -212,7 +212,31 @@
"confirm": "Подтвердить",
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить.",
"search": "Поиск инструментов...",
- "no_results": "Инструменты не найдены"
+ "no_results": "Инструменты не найдены",
+ "error": "Ошибка"
+ },
+ "maintenance": {
+ "title": "Обслуживание и данные",
+ "description": "Управляйте локальными данными, очищайте кэши и создавайте резервные копии разговоров.",
+ "clear_messages": "Очистить все сообщения",
+ "clear_messages_desc": "Навсегда удалить все отправленные и полученные сообщения.",
+ "clear_announces": "Очистить все объявления",
+ "clear_announces_desc": "Удалить всех обнаруженных участников и узлы из кэша.",
+ "clear_nomadnet_favs": "Очистить избранное NomadNetwork",
+ "clear_nomadnet_favs_desc": "Удалить все сохраненные узлы NomadNetwork из избранного.",
+ "clear_archives": "Очистить архивы страниц",
+ "clear_archives_desc": "Удалить все кэшированные страницы NomadNetwork.",
+ "export_messages": "Экспортировать сообщения",
+ "export_messages_desc": "Загрузить все разговоры в виде JSON-файла.",
+ "import_messages": "Импортировать сообщения",
+ "import_messages_desc": "Восстановить разговоры из JSON-файла.",
+ "clear_confirm": "Вы уверены? Это действие нельзя отменить.",
+ "messages_cleared": "Сообщения успешно очищены",
+ "announces_cleared": "Объявления успешно очищены",
+ "favourites_cleared": "Избранное успешно очищено",
+ "archives_cleared": "Архивы успешно очищены",
+ "import_success": "Успешно импортировано {count} сообщений",
+ "import_failed": "Не удалось импортировать сообщения"
},
"identities": {
"title": "Личности",
@@ -695,6 +719,18 @@
"title": "Бумажное сообщение",
"description": "Создание и чтение подписанных бумажных сообщений LXMF через QR-коды."
},
+ "rns_page_node": {
+ "title": "RNS Page-узел",
+ "description": "Хостинг микрон-страниц Nomadnet и удобный обмен файлами."
+ },
+ "rns_tunnel": {
+ "title": "RNS-туннель",
+ "description": "Туннелирование обычного IP-трафика через сеть Reticulum."
+ },
+ "rns_filesync": {
+ "title": "RNS-файлообмен",
+ "description": "Эффективный обмен и синхронизация файлов с другими узлами mesh."
+ },
"bots": {
"title": "LXMFy Боты",
"description": "Управление автоматизированными ботами для эха, заметок и напоминаний с помощью LXMFy."
@@ -1051,7 +1087,11 @@
"restart_start": "Запустить MeshChatX",
"skip_setup": "Пропустить настройку",
"continue": "Продолжить",
- "skip_confirm": "Вы уверены, что хотите пропустить настройку? Вам придется добавить интерфейсы позже вручную."
+ "skip_confirm": "Вы уверены, что хотите пропустить настройку? Вам придется добавить интерфейсы позже вручную.",
+ "discovery_question": "Вы хотите использовать обнаружение интерфейсов сообщества и автоподключение?",
+ "discovery_desc": "Это позволяет MeshChatX автоматически находить и подключаться к публичным узлам сообщества рядом с вами или в интернете.",
+ "yes": "Да, использовать обнаружение",
+ "no": "Нет, ручная настройка"
},
"command_palette": {
"search_placeholder": "Поиск команд, навигация или поиск узлов...",
diff --git a/meshchatx/src/frontend/style.css b/meshchatx/src/frontend/style.css
index c5b0f798..8ca5fbe8 100644
--- a/meshchatx/src/frontend/style.css
+++ b/meshchatx/src/frontend/style.css
@@ -168,6 +168,33 @@ select.input-field option {
scrollbar-width: none;
}
+.custom-scrollbar::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb {
+ background: #cbd5e1;
+ border-radius: 10px;
+ border: none;
+}
+
+.dark .custom-scrollbar::-webkit-scrollbar-thumb {
+ background: #3f3f46;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: #94a3b8;
+}
+
+.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: #52525b;
+}
+
@keyframes spin-reverse {
from {
transform: rotate(360deg);
diff --git a/tests/backend/test_identity_restore.py b/tests/backend/test_identity_restore.py
new file mode 100644
index 00000000..c0efe251
--- /dev/null
+++ b/tests/backend/test_identity_restore.py
@@ -0,0 +1,85 @@
+import os
+import shutil
+import tempfile
+import base64
+import unittest
+from unittest.mock import MagicMock, patch
+from meshchatx.src.backend.identity_manager import IdentityManager
+
+
+class TestIdentityRestore(unittest.TestCase):
+ def setUp(self):
+ self.temp_dir = tempfile.mkdtemp()
+ self.identity_manager = IdentityManager(self.temp_dir)
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_dir)
+
+ @patch("RNS.Identity")
+ @patch("meshchatx.src.backend.identity_manager.DatabaseProvider")
+ @patch("meshchatx.src.backend.identity_manager.DatabaseSchema")
+ def test_restore_identity_from_bytes(
+ self, mock_schema, mock_provider, mock_rns_identity
+ ):
+ # Setup mock identity
+ mock_id_instance = MagicMock()
+ mock_id_instance.hash = b"test_hash_32_bytes_long_01234567"
+ mock_id_instance.get_private_key.return_value = b"test_private_key"
+ mock_rns_identity.from_bytes.return_value = mock_id_instance
+
+ identity_bytes = b"some_identity_bytes"
+ result = self.identity_manager.restore_identity_from_bytes(identity_bytes)
+
+ identity_hash = mock_id_instance.hash.hex()
+ self.assertEqual(result["hash"], identity_hash)
+ self.assertEqual(result["display_name"], "Restored Identity")
+
+ # Verify files were created
+ identity_dir = os.path.join(self.temp_dir, "identities", identity_hash)
+ self.assertTrue(os.path.exists(identity_dir))
+ self.assertTrue(os.path.exists(os.path.join(identity_dir, "identity")))
+ self.assertTrue(os.path.exists(os.path.join(identity_dir, "metadata.json")))
+
+ # Verify private key was written
+ with open(os.path.join(identity_dir, "identity"), "rb") as f:
+ self.assertEqual(f.read(), b"test_private_key")
+
+ @patch("RNS.Identity")
+ @patch("meshchatx.src.backend.identity_manager.DatabaseProvider")
+ @patch("meshchatx.src.backend.identity_manager.DatabaseSchema")
+ def test_restore_identity_from_base32(
+ self, mock_schema, mock_provider, mock_rns_identity
+ ):
+ # Setup mock identity
+ mock_id_instance = MagicMock()
+ mock_id_instance.hash = b"test_hash_32_bytes_long_01234567"
+ mock_id_instance.get_private_key.return_value = b"test_private_key"
+ mock_rns_identity.from_bytes.return_value = mock_id_instance
+
+ identity_bytes = b"some_identity_bytes"
+ base32_value = base64.b32encode(identity_bytes).decode("utf-8")
+
+ result = self.identity_manager.restore_identity_from_base32(base32_value)
+
+ identity_hash = mock_id_instance.hash.hex()
+ self.assertEqual(result["hash"], identity_hash)
+
+ # Verify from_bytes was called with the decoded bytes
+ mock_rns_identity.from_bytes.assert_called_with(identity_bytes)
+
+ @patch("RNS.Identity")
+ def test_restore_identity_invalid_bytes(self, mock_rns_identity):
+ mock_rns_identity.from_bytes.return_value = None
+ with self.assertRaises(ValueError) as cm:
+ self.identity_manager.restore_identity_from_bytes(b"invalid")
+ self.assertIn("Could not load identity from bytes", str(cm.exception))
+
+ @patch("RNS.Identity")
+ def test_restore_identity_invalid_base32(self, mock_rns_identity):
+ with self.assertRaises(ValueError) as cm:
+ self.identity_manager.restore_identity_from_base32("invalid-base32-!!!")
+ self.assertIn("Invalid base32 identity", str(cm.exception))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/backend/test_maintenance.py b/tests/backend/test_maintenance.py
new file mode 100644
index 00000000..7e191e34
--- /dev/null
+++ b/tests/backend/test_maintenance.py
@@ -0,0 +1,64 @@
+import unittest
+from unittest.mock import MagicMock
+from meshchatx.src.backend.database.messages import MessageDAO
+from meshchatx.src.backend.database.announces import AnnounceDAO
+from meshchatx.src.backend.database.misc import MiscDAO
+
+
+class TestMaintenance(unittest.TestCase):
+ def setUp(self):
+ self.provider = MagicMock()
+ self.messages_dao = MessageDAO(self.provider)
+ self.announces_dao = AnnounceDAO(self.provider)
+ self.misc_dao = MiscDAO(self.provider)
+
+ def test_delete_all_lxmf_messages(self):
+ self.messages_dao.delete_all_lxmf_messages()
+ self.assertEqual(self.provider.execute.call_count, 2)
+ calls = self.provider.execute.call_args_list
+ self.assertIn("DELETE FROM lxmf_messages", calls[0][0][0])
+ self.assertIn("DELETE FROM lxmf_conversation_read_state", calls[1][0][0])
+
+ def test_delete_all_announces(self):
+ # Test without aspect
+ self.announces_dao.delete_all_announces()
+ self.provider.execute.assert_called_with("DELETE FROM announces")
+
+ # Test with aspect
+ self.announces_dao.delete_all_announces(aspect="test_aspect")
+ self.provider.execute.assert_called_with(
+ "DELETE FROM announces WHERE aspect = ?", ("test_aspect",)
+ )
+
+ def test_delete_all_favourites(self):
+ # Test without aspect
+ self.announces_dao.delete_all_favourites()
+ self.provider.execute.assert_called_with("DELETE FROM favourite_destinations")
+
+ # Test with aspect
+ self.announces_dao.delete_all_favourites(aspect="test_aspect")
+ self.provider.execute.assert_called_with(
+ "DELETE FROM favourite_destinations WHERE aspect = ?", ("test_aspect",)
+ )
+
+ def test_delete_archived_pages(self):
+ self.misc_dao.delete_archived_pages()
+ self.provider.execute.assert_called_with("DELETE FROM archived_pages")
+
+ def test_upsert_lxmf_message(self):
+ msg_data = {
+ "hash": "test_hash",
+ "source_hash": "source",
+ "destination_hash": "dest",
+ "peer_hash": "peer",
+ "content": "hello",
+ }
+ self.messages_dao.upsert_lxmf_message(msg_data)
+ self.provider.execute.assert_called()
+ args, _ = self.provider.execute.call_args
+ self.assertIn("INSERT INTO lxmf_messages", args[0])
+ self.assertIn("ON CONFLICT(hash) DO UPDATE SET", args[0])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/backend/test_message_handler.py b/tests/backend/test_message_handler.py
index 7b8c538d..b4e2a143 100644
--- a/tests/backend/test_message_handler.py
+++ b/tests/backend/test_message_handler.py
@@ -22,10 +22,14 @@ class TestMessageHandler(unittest.TestCase):
def test_delete_conversation(self):
self.handler.delete_conversation("local", "dest")
- self.db.provider.execute.assert_called()
- args, kwargs = self.db.provider.execute.call_args
- self.assertIn("DELETE FROM lxmf_messages", args[0])
- self.assertIn("dest", args[1])
+ self.assertEqual(self.db.provider.execute.call_count, 2)
+ call_args_list = self.db.provider.execute.call_args_list
+ first_call_args, _ = call_args_list[0]
+ second_call_args, _ = call_args_list[1]
+ self.assertIn("DELETE FROM lxmf_messages", first_call_args[0])
+ self.assertIn("dest", first_call_args[1])
+ self.assertIn("DELETE FROM lxmf_conversation_folders", second_call_args[0])
+ self.assertIn("dest", second_call_args[1])
if __name__ == "__main__":
diff --git a/tests/frontend/MessagesSidebar.test.js b/tests/frontend/MessagesSidebar.test.js
index 1bf0914c..e7c150c5 100644
--- a/tests/frontend/MessagesSidebar.test.js
+++ b/tests/frontend/MessagesSidebar.test.js
@@ -1,8 +1,18 @@
import { mount } from "@vue/test-utils";
-import { describe, it, expect, vi } from "vitest";
+import { describe, it, expect, vi, beforeEach } from "vitest";
import MessagesSidebar from "@/components/messages/MessagesSidebar.vue";
describe("MessagesSidebar.vue", () => {
+ beforeEach(() => {
+ // Mock localStorage
+ global.localStorage = {
+ getItem: vi.fn(() => null),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ clear: vi.fn(),
+ };
+ });
+
const defaultProps = {
peers: {},
conversations: [],
@@ -38,11 +48,13 @@ describe("MessagesSidebar.vue", () => {
const wrapper = mountMessagesSidebar({ conversations });
- const nameElement = wrapper.find(".truncate");
+ const nameElement = wrapper.find(".conversation-item .truncate");
expect(nameElement.exists()).toBe(true);
expect(nameElement.text()).toContain("Long Name");
- const previewElement = wrapper.findAll(".truncate").find((el) => el.text().includes("Message"));
+ const previewElement = wrapper
+ .findAll(".conversation-item .truncate")
+ .find((el) => el.text().includes("Message"));
expect(previewElement.exists()).toBe(true);
});
@@ -60,7 +72,7 @@ describe("MessagesSidebar.vue", () => {
expect(scrollContainer.exists()).toBe(true);
expect(scrollContainer.classes()).toContain("overflow-y-auto");
- const conversationItems = wrapper.findAll("div.overflow-y-auto .cursor-pointer");
+ const conversationItems = wrapper.findAll(".conversation-item");
expect(conversationItems.length).toBe(100);
});
diff --git a/tests/frontend/Performance.test.js b/tests/frontend/Performance.test.js
index 964bac49..a32fb345 100644
--- a/tests/frontend/Performance.test.js
+++ b/tests/frontend/Performance.test.js
@@ -106,7 +106,7 @@ describe("UI Performance and Memory Tests", () => {
`Rendered ${numConvs} conversations in ${renderTime.toFixed(2)}ms, Memory growth: ${memGrowth.toFixed(2)}MB`
);
- expect(wrapper.findAll(".flex.cursor-pointer").length).toBe(numConvs);
+ expect(wrapper.findAll(".conversation-item").length).toBe(numConvs);
expect(renderTime).toBeLessThan(5000);
expect(memGrowth).toBeLessThan(200); // Adjusted for JSDOM/Node.js overhead with 2000 items
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────